skalpel 4.0.9 → 4.0.10
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/incremental-ingest.mjs +37 -17
- package/package.json +1 -1
- package/skalpel-hook-session.mjs +27 -47
- package/skalpel-hook.mjs +50 -126
- package/skalpel-statusline.mjs +117 -45
package/incremental-ingest.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// process kill, or accepted-response loss cannot lose the completed session or duplicate paid work.
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import {
|
|
9
|
+
appendFileSync,
|
|
9
10
|
closeSync,
|
|
10
11
|
existsSync,
|
|
11
12
|
mkdirSync,
|
|
@@ -23,7 +24,6 @@ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
23
24
|
import { gzipSync } from "node:zlib";
|
|
24
25
|
|
|
25
26
|
import { identity } from "./auth.mjs";
|
|
26
|
-
import { recordInsight } from "./insights.mjs";
|
|
27
27
|
|
|
28
28
|
const OUTBOX_DIR = "ingest-outbox";
|
|
29
29
|
const LEDGER_FILE = "delivered.json";
|
|
@@ -159,11 +159,31 @@ function saveItem(home, item) {
|
|
|
159
159
|
writeAtomic(itemPath(home, item.key), item);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
// INFRA STATUS is NOT product output. Background context-graph maintenance (retry-queued, waiting on
|
|
163
|
+
// provider quota, snapshot-missing, membership-required) is an operational condition, not an insight the
|
|
164
|
+
// user asked for — so it goes to a DOCTOR channel (~/.skalpel/doctor.log + stderr), never to the
|
|
165
|
+
// user-facing insights feed. `skalpel doctor`/stderr is where an operator looks; the insights feed stays
|
|
166
|
+
// exclusively product signal. Fail-open: a doctor write must never break the fail-safe SessionEnd hook.
|
|
167
|
+
function doctorLog(home, message) {
|
|
168
|
+
const line = `${new Date().toISOString()} [ingest] ${message}`;
|
|
169
|
+
try {
|
|
170
|
+
process.stderr.write(line + "\n");
|
|
171
|
+
} catch {
|
|
172
|
+
/* stderr may be closed under the async hook — never throw */
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
mkdirSync(skalpelDir(home), { recursive: true });
|
|
176
|
+
appendFileSync(join(skalpelDir(home), "doctor.log"), line + "\n");
|
|
177
|
+
} catch {
|
|
178
|
+
/* the doctor trail is best-effort; never block ingest on it */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function notifyOnce(home, item, state, message, doctorFn) {
|
|
163
183
|
if (item.notified_state === state) return;
|
|
164
184
|
item.notified_state = state;
|
|
165
185
|
saveItem(home, item);
|
|
166
|
-
|
|
186
|
+
doctorFn(home, message);
|
|
167
187
|
}
|
|
168
188
|
|
|
169
189
|
function listItems(home, preferredKey) {
|
|
@@ -200,7 +220,7 @@ function retryDelayMs(generation) {
|
|
|
200
220
|
return Math.min(60 * 60 * 1000, 5 * 60 * 1000 * 2 ** Math.max(0, generation - 1));
|
|
201
221
|
}
|
|
202
222
|
|
|
203
|
-
function scheduleTerminalRetry(home, item, status, now,
|
|
223
|
+
function scheduleTerminalRetry(home, item, status, now, doctorFn) {
|
|
204
224
|
const nextGeneration = (item.generation || 0) + 1;
|
|
205
225
|
item.last_job_id = item.job_id || null;
|
|
206
226
|
item.job_id = null;
|
|
@@ -218,7 +238,7 @@ function scheduleTerminalRetry(home, item, status, now, insightFn) {
|
|
|
218
238
|
item,
|
|
219
239
|
`retry-${nextGeneration}`,
|
|
220
240
|
"context graph update hit an error; a safe retry is queued",
|
|
221
|
-
|
|
241
|
+
doctorFn,
|
|
222
242
|
);
|
|
223
243
|
return;
|
|
224
244
|
}
|
|
@@ -230,12 +250,12 @@ function scheduleTerminalRetry(home, item, status, now, insightFn) {
|
|
|
230
250
|
item,
|
|
231
251
|
"fallback-required",
|
|
232
252
|
"context graph update needs a retry; reopen Skalpel to run the history catch-up",
|
|
233
|
-
|
|
253
|
+
doctorFn,
|
|
234
254
|
);
|
|
235
255
|
}
|
|
236
256
|
|
|
237
257
|
async function pollAcceptedJob(home, item, auth, options, deadline) {
|
|
238
|
-
const { api, fetchFn,
|
|
258
|
+
const { api, fetchFn, doctorFn, now, pollIntervalMs, requestTimeoutMs, sleepFn } = options;
|
|
239
259
|
const statusURL = `${api}/ingest/status?job_id=${encodeURIComponent(item.job_id)}`;
|
|
240
260
|
for (;;) {
|
|
241
261
|
let response;
|
|
@@ -258,7 +278,7 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
|
|
|
258
278
|
return "pending";
|
|
259
279
|
}
|
|
260
280
|
if (response.status === 404) {
|
|
261
|
-
scheduleTerminalRetry(home, item, { state: "error", error: "job not found" }, now,
|
|
281
|
+
scheduleTerminalRetry(home, item, { state: "error", error: "job not found" }, now, doctorFn);
|
|
262
282
|
return "retry";
|
|
263
283
|
}
|
|
264
284
|
if (!response.ok) {
|
|
@@ -295,7 +315,7 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
|
|
|
295
315
|
// carries retry_at, and resumes the SAME job. Pin both contracts; never infer one from the
|
|
296
316
|
// state string alone.
|
|
297
317
|
if (item.terminal && item.requires_replay) {
|
|
298
|
-
scheduleTerminalRetry(home, item, status, now,
|
|
318
|
+
scheduleTerminalRetry(home, item, status, now, doctorFn);
|
|
299
319
|
return "retry";
|
|
300
320
|
}
|
|
301
321
|
notifyOnce(
|
|
@@ -303,16 +323,16 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
|
|
|
303
323
|
item,
|
|
304
324
|
"waiting-quota",
|
|
305
325
|
"context graph update is safely queued while provider capacity recovers",
|
|
306
|
-
|
|
326
|
+
doctorFn,
|
|
307
327
|
);
|
|
308
328
|
return "pending";
|
|
309
329
|
}
|
|
310
330
|
if (item.terminal && item.requires_replay) {
|
|
311
|
-
scheduleTerminalRetry(home, item, status, now,
|
|
331
|
+
scheduleTerminalRetry(home, item, status, now, doctorFn);
|
|
312
332
|
return "retry";
|
|
313
333
|
}
|
|
314
334
|
if (item.state === "error" || item.state === "failed") {
|
|
315
|
-
scheduleTerminalRetry(home, item, status, now,
|
|
335
|
+
scheduleTerminalRetry(home, item, status, now, doctorFn);
|
|
316
336
|
return "retry";
|
|
317
337
|
}
|
|
318
338
|
if (now() + pollIntervalMs >= deadline) return "pending";
|
|
@@ -321,7 +341,7 @@ async function pollAcceptedJob(home, item, auth, options, deadline) {
|
|
|
321
341
|
}
|
|
322
342
|
|
|
323
343
|
async function submitItem(home, item, auth, options, deadline) {
|
|
324
|
-
const { api, fetchFn,
|
|
344
|
+
const { api, fetchFn, doctorFn, now, requestTimeoutMs } = options;
|
|
325
345
|
if (!item.job_id) {
|
|
326
346
|
let transcript;
|
|
327
347
|
try {
|
|
@@ -334,7 +354,7 @@ async function submitItem(home, item, auth, options, deadline) {
|
|
|
334
354
|
item,
|
|
335
355
|
"snapshot-missing",
|
|
336
356
|
"context graph update needs a retry; reopen Skalpel to run the history catch-up",
|
|
337
|
-
|
|
357
|
+
doctorFn,
|
|
338
358
|
);
|
|
339
359
|
return "pending";
|
|
340
360
|
}
|
|
@@ -378,7 +398,7 @@ async function submitItem(home, item, auth, options, deadline) {
|
|
|
378
398
|
item,
|
|
379
399
|
"membership-required",
|
|
380
400
|
"context graph update is queued until membership access is available",
|
|
381
|
-
|
|
401
|
+
doctorFn,
|
|
382
402
|
);
|
|
383
403
|
return "pending";
|
|
384
404
|
}
|
|
@@ -469,7 +489,7 @@ export function enqueueCompletedSession(payload, options = {}) {
|
|
|
469
489
|
export async function drainIncrementalOutbox(options = {}) {
|
|
470
490
|
const home = options.home || homedir();
|
|
471
491
|
const now = options.now || Date.now;
|
|
472
|
-
const
|
|
492
|
+
const doctorFn = options.doctorFn || doctorLog;
|
|
473
493
|
ensureOutbox(home);
|
|
474
494
|
const lockPath = join(outboxDir(home), DRAIN_LOCK);
|
|
475
495
|
if (!acquireLock(lockPath, now)) return { locked: true, pending: listItems(home).length };
|
|
@@ -490,7 +510,7 @@ export async function drainIncrementalOutbox(options = {}) {
|
|
|
490
510
|
const common = {
|
|
491
511
|
api: options.api || clientAPI(home),
|
|
492
512
|
fetchFn: options.fetchFn || fetch,
|
|
493
|
-
|
|
513
|
+
doctorFn,
|
|
494
514
|
now,
|
|
495
515
|
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
496
516
|
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
package/package.json
CHANGED
package/skalpel-hook-session.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Wire it (Claude ~/.claude/settings.json):
|
|
8
8
|
// {"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node …/skalpel-hook-session.mjs"}]}]}}
|
|
9
|
-
import { readFileSync, writeFileSync,
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { identity } from "./auth.mjs";
|
|
@@ -14,43 +14,44 @@ import { record } from "./metrics.mjs";
|
|
|
14
14
|
import { recordInsight } from "./insights.mjs";
|
|
15
15
|
import { graphReadyNote } from "./reveal.mjs";
|
|
16
16
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// RECAP — the felt wins, surfaced at a break point (peak-end rule). Two break points:
|
|
17
|
+
// RECAP — surfaced at a break point (peak-end rule). It reports a COUNT of the steers skalpel actually
|
|
18
|
+
// showed (a reproducible number from session.json's steers array) and the recurring work-type — NEVER a
|
|
19
|
+
// "saved ~Xmin" figure (that was a server estimate rendered as measured time; it is gone). Two break
|
|
20
|
+
// points:
|
|
22
21
|
// • "compact" → mid-session, right AFTER compaction (a natural chapter break, user present). The
|
|
23
22
|
// recap is CUMULATIVE ("so far this session") and the tally is NOT reset — work goes on.
|
|
24
23
|
// • else → a fresh session start; recap the PREVIOUS session's tally (re-engagement), then reset.
|
|
25
24
|
// PreCompact/SessionEnd stdout are silent in Claude Code; the post-compact SessionStart CAN inject, so
|
|
26
25
|
// that's where the compaction recap rides. Read BEFORE resetStats wipes it.
|
|
27
26
|
const SESSION_PATH = join(homedir(), ".skalpel", "session.json");
|
|
28
|
-
// Returns { note,
|
|
29
|
-
//
|
|
27
|
+
// Returns { note, n } — the injected directive AND the steer count it cites, so the local insight record
|
|
28
|
+
// (below) can reuse the exact same number instead of re-deriving it.
|
|
30
29
|
function buildRecap(source) {
|
|
31
30
|
try {
|
|
32
31
|
const s = JSON.parse(readFileSync(SESSION_PATH, "utf8"));
|
|
33
|
-
const mins = Math.round(s.mins || 0);
|
|
34
32
|
const steers = Array.isArray(s.steers) ? s.steers : [];
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
const n = steers.length;
|
|
34
|
+
// Recap only when skalpel actually showed steers this session — a real, reproducible count.
|
|
35
|
+
if (n < 1) return { note: null, n: 0 };
|
|
37
36
|
const byWt = {};
|
|
38
37
|
for (const st of steers) if (st.wt) byWt[st.wt] = (byWt[st.wt] || 0) + 1;
|
|
39
38
|
const top = Object.entries(byWt).sort((a, b) => b[1] - a[1])[0];
|
|
40
|
-
const trapTail = top ? ` — ${top[0]} kept coming up` : "";
|
|
39
|
+
const trapTail = top ? ` — ${top[0]} kept coming up (${top[1]}×)` : "";
|
|
41
40
|
const compact = source === "compact";
|
|
42
41
|
const when = compact ? "in the stretch before this compaction" : "last session";
|
|
42
|
+
const count = `${n} recurring-pattern moment${n === 1 ? "" : "s"}`;
|
|
43
43
|
const line = compact
|
|
44
|
-
? `“🔬 skalpel · in that last stretch
|
|
45
|
-
: `“🔬 skalpel · last session I
|
|
44
|
+
? `“🔬 skalpel · flagged ${count} in that last stretch${trapTail} — fresh context now. Run \`skalpel autopsy\` for the receipts.”`
|
|
45
|
+
: `“🔬 skalpel · last session I flagged ${count}${trapTail}. Run \`skalpel autopsy\` for the receipts.”`;
|
|
46
46
|
const note =
|
|
47
|
-
`[skalpel — RECAP (${when}):
|
|
47
|
+
`[skalpel — RECAP (${when}): skalpel flagged ${count}` +
|
|
48
48
|
(top ? `; the recurring trap was ${top[0]} (${top[1]}×)` : "") +
|
|
49
|
-
`. Open THIS message with ONE natural line so they see it
|
|
50
|
-
"Then continue normally.
|
|
51
|
-
|
|
49
|
+
`. Open THIS message with ONE natural line so they see it — ${line} ` +
|
|
50
|
+
"Then continue normally. Do NOT invent any time-saved number — the only honest figures live in " +
|
|
51
|
+
"`skalpel autopsy`. Skip the recap if it doesn't fit.]";
|
|
52
|
+
return { note, n };
|
|
52
53
|
} catch {
|
|
53
|
-
return { note: null,
|
|
54
|
+
return { note: null, n: 0 }; // no prior session / fresh install
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -58,34 +59,14 @@ function resetStats() {
|
|
|
58
59
|
try {
|
|
59
60
|
const dir = join(homedir(), ".skalpel");
|
|
60
61
|
mkdirSync(dir, { recursive: true });
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
session_hours: 0,
|
|
66
|
-
session_cost: 0,
|
|
67
|
-
session_events: 0,
|
|
68
|
-
total_hours: 0,
|
|
69
|
-
total_cost: 0,
|
|
70
|
-
total_events: 0,
|
|
71
|
-
};
|
|
72
|
-
try {
|
|
73
|
-
s = { ...s, ...JSON.parse(readFileSync(p, "utf8")) };
|
|
74
|
-
} catch {
|
|
75
|
-
/* fresh */
|
|
76
|
-
}
|
|
77
|
-
s.session_hours = 0;
|
|
78
|
-
s.session_cost = 0;
|
|
79
|
-
s.session_events = 0;
|
|
80
|
-
const tmp = `${p}.tmp`;
|
|
81
|
-
writeFileSync(tmp, JSON.stringify(s));
|
|
82
|
-
renameSync(tmp, p);
|
|
83
|
-
// also clear any pending n+1 prediction from the prior session — a stale prediction must never
|
|
84
|
-
// false-credit an avoided detour across a session boundary.
|
|
62
|
+
// The old stats.json "time saved" accumulator is GONE — nothing writes or reads it anymore, so there
|
|
63
|
+
// is nothing to zero here. This just clears the per-session working state so each session starts fresh.
|
|
64
|
+
// Clear any pending n+1 prediction from the prior session — a stale prediction must never
|
|
65
|
+
// false-attribute a break-out across a session boundary.
|
|
85
66
|
writeFileSync(join(dir, "pending.json"), "{}");
|
|
86
67
|
writeFileSync(join(dir, "steer.json"), "{}"); // no live steer until the first prompt this session
|
|
87
68
|
writeFileSync(join(dir, "traj.json"), "[]"); // fresh work-type trajectory each session
|
|
88
|
-
writeFileSync(join(dir, "session.json"), JSON.stringify({ steers: []
|
|
69
|
+
writeFileSync(join(dir, "session.json"), JSON.stringify({ steers: [] })); // recap tally (steer count)
|
|
89
70
|
// clear the SESSION-scoped mute (a shush lasts one session), but PRESERVE persistent prefs
|
|
90
71
|
// (per-trap "fewer"/"more", usefulness scores) — those are the learned personalization.
|
|
91
72
|
try {
|
|
@@ -131,7 +112,7 @@ async function main() {
|
|
|
131
112
|
}
|
|
132
113
|
// source distinguishes a real new session (startup/resume/clear) from a mid-session compaction.
|
|
133
114
|
const source = payload.source || payload.hookSpecificOutput?.source || "startup";
|
|
134
|
-
const { note: recapNote,
|
|
115
|
+
const { note: recapNote, n: recapN } = buildRecap(source); // read the tally BEFORE resetStats wipes it
|
|
135
116
|
// Compaction CONTINUES the session — keep the counter + tally running; only a real start zeroes them.
|
|
136
117
|
if (source !== "compact") resetStats();
|
|
137
118
|
// identity from the Google sign-in (refreshed if near expiry); the token authenticates the read so
|
|
@@ -180,8 +161,7 @@ async function main() {
|
|
|
180
161
|
if (recapNote)
|
|
181
162
|
recordInsight({
|
|
182
163
|
kind: "recap",
|
|
183
|
-
display: `last session skalpel
|
|
184
|
-
mins: recapMins,
|
|
164
|
+
display: `last session skalpel flagged ${recapN} recurring-pattern moment${recapN === 1 ? "" : "s"}`,
|
|
185
165
|
});
|
|
186
166
|
if (context)
|
|
187
167
|
recordInsight({
|
package/skalpel-hook.mjs
CHANGED
|
@@ -108,46 +108,10 @@ function detectCascade(payload) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// zeroes it (see skalpel-hook-session.mjs). Read by the statusline, Brave-ad-block-counter style.
|
|
116
|
-
const STATS_PATH = join(homedir(), ".skalpel", "stats.json");
|
|
117
|
-
const EMPTY_STATS = {
|
|
118
|
-
session_hours: 0,
|
|
119
|
-
session_cost: 0,
|
|
120
|
-
session_events: 0,
|
|
121
|
-
total_hours: 0,
|
|
122
|
-
total_cost: 0,
|
|
123
|
-
total_events: 0,
|
|
124
|
-
};
|
|
125
|
-
function bumpStats(hours, cost) {
|
|
126
|
-
try {
|
|
127
|
-
let s = { ...EMPTY_STATS };
|
|
128
|
-
try {
|
|
129
|
-
s = { ...s, ...JSON.parse(readFileSync(STATS_PATH, "utf8")) };
|
|
130
|
-
} catch {
|
|
131
|
-
/* missing or corrupt -> start fresh, never block on it */
|
|
132
|
-
}
|
|
133
|
-
const r2 = (n) => Math.round(n * 100) / 100;
|
|
134
|
-
const h = hours || 0;
|
|
135
|
-
const c = cost || 0;
|
|
136
|
-
// increment BOTH: session (resets each session) and total (lifetime, never resets)
|
|
137
|
-
s.session_hours = r2(s.session_hours + h);
|
|
138
|
-
s.session_cost = r2(s.session_cost + c);
|
|
139
|
-
s.session_events += 1;
|
|
140
|
-
s.total_hours = r2(s.total_hours + h);
|
|
141
|
-
s.total_cost = r2(s.total_cost + c);
|
|
142
|
-
s.total_events += 1;
|
|
143
|
-
mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
|
|
144
|
-
const tmp = `${STATS_PATH}.tmp`;
|
|
145
|
-
writeFileSync(tmp, JSON.stringify(s));
|
|
146
|
-
renameSync(tmp, STATS_PATH); // atomic swap — a concurrent read never sees a half-written file
|
|
147
|
-
} catch {
|
|
148
|
-
/* stats are a bonus, never block emitting the injection */
|
|
149
|
-
}
|
|
150
|
-
}
|
|
111
|
+
// NOTE: the old stats.json "time saved" accumulator (session_hours/total_hours/…/total_events) is GONE.
|
|
112
|
+
// It summed a SERVER estimate of expected-waste-ahead and the statusline rendered it as "~Xh saved" — an
|
|
113
|
+
// estimate shown as a measurement. Nothing writes or reads stats.json anymore; the only honest local
|
|
114
|
+
// receipts are what `skalpel autopsy` re-derives from the raw transcripts on disk.
|
|
151
115
|
|
|
152
116
|
// n+1 CONFIRMATION store — the honest, Brave-style counter. At turn N we PREDICT a rework path but
|
|
153
117
|
// credit NOTHING yet; we stash the prediction here. At turn N+1 we check whether you actually broke
|
|
@@ -182,13 +146,12 @@ function readSteer() {
|
|
|
182
146
|
return {};
|
|
183
147
|
}
|
|
184
148
|
}
|
|
185
|
-
function writeSteer(label,
|
|
186
|
-
// label = server's plain-English "what skalpel's doing" (or null on a silent turn)
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// (
|
|
190
|
-
//
|
|
191
|
-
// NEXT turn's adherence record can reference exactly this steer; null on a silent turn.
|
|
149
|
+
function writeSteer(label, type, wt, id) {
|
|
150
|
+
// label = server's plain-English "what skalpel's doing" (or null on a silent turn) — a DESCRIPTION the
|
|
151
|
+
// statusline can show, never a number. The old `credit` field (a +Xmin estimate rendered as a measured
|
|
152
|
+
// tick) is REMOVED. type/wt = the raw steer identity (e.g. "TELL"/"refine") so the NEXT turn's feedback
|
|
153
|
+
// phrase ("fewer like this") can target it. id = the steer's join key (minted once per steer turn, also
|
|
154
|
+
// on that steer's insight row) so the NEXT turn's adherence record can reference exactly this steer.
|
|
192
155
|
try {
|
|
193
156
|
mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
|
|
194
157
|
const tmp = `${STEER_PATH}.tmp`;
|
|
@@ -196,7 +159,6 @@ function writeSteer(label, creditMins, type, wt, id) {
|
|
|
196
159
|
tmp,
|
|
197
160
|
JSON.stringify({
|
|
198
161
|
label: label || null,
|
|
199
|
-
credit: creditMins || null,
|
|
200
162
|
type: type || null,
|
|
201
163
|
wt: wt || null,
|
|
202
164
|
id: id || null,
|
|
@@ -228,8 +190,9 @@ function readPrefs() {
|
|
|
228
190
|
|
|
229
191
|
// ADHERENCE — the implicit workhorse (Grammarly's accept/reject, not thumbs). Per steer TYPE we track,
|
|
230
192
|
// from BEHAVIOR alone: shown (fired), acted (you moved off the work-type it pushed you off next turn),
|
|
231
|
-
// paid (acting also
|
|
232
|
-
// patterns you actually act on —
|
|
193
|
+
// paid (acting also coincided with an n+1 BREAK-OUT — expected-waste-ahead dropped). All categorical
|
|
194
|
+
// revealed preference — which of your patterns you actually act on — never a number shown to the user;
|
|
195
|
+
// it accretes across sessions into your personal steer model.
|
|
233
196
|
function recordAdherence(prefs, prev, thisWt, paid) {
|
|
234
197
|
if (!prev || !prev.type || !thisWt) return null;
|
|
235
198
|
prefs.stats = prefs.stats || {};
|
|
@@ -298,22 +261,21 @@ function parseControl(q) {
|
|
|
298
261
|
return null;
|
|
299
262
|
}
|
|
300
263
|
|
|
301
|
-
// SESSION EVENT LOG — the raw material for the end-of-session recap (peak-end rule). One row per turn
|
|
302
|
-
//
|
|
264
|
+
// SESSION EVENT LOG — the raw material for the end-of-session recap (peak-end rule). One row per turn a
|
|
265
|
+
// steer actually fired: a COUNT of real steers shown, plus their work-types (for "refine kept coming up").
|
|
266
|
+
// No minutes, no "caught" tally — those were estimate-derived. SessionStart resets it; SessionEnd renders
|
|
267
|
+
// it as a count. (`ev.caughtMins` is intentionally ignored; the estimate path that fed it is gone.)
|
|
303
268
|
const SESSION_PATH = join(homedir(), ".skalpel", "session.json");
|
|
304
269
|
function logSessionEvent(ev) {
|
|
305
270
|
try {
|
|
306
|
-
let s = { steers: []
|
|
271
|
+
let s = { steers: [] };
|
|
307
272
|
try {
|
|
308
|
-
|
|
273
|
+
const prev = JSON.parse(readFileSync(SESSION_PATH, "utf8"));
|
|
274
|
+
if (Array.isArray(prev?.steers)) s.steers = prev.steers;
|
|
309
275
|
} catch {
|
|
310
276
|
/* fresh session */
|
|
311
277
|
}
|
|
312
278
|
if (ev.type) s.steers.push({ type: ev.type, wt: ev.wt || null, ts: Date.now() });
|
|
313
|
-
if (ev.caughtMins) {
|
|
314
|
-
s.caught += 1;
|
|
315
|
-
s.mins += ev.caughtMins;
|
|
316
|
-
}
|
|
317
279
|
mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
|
|
318
280
|
const tmp = `${SESSION_PATH}.tmp`;
|
|
319
281
|
writeFileSync(tmp, JSON.stringify(s));
|
|
@@ -494,12 +456,10 @@ async function main() {
|
|
|
494
456
|
const timer = setTimeout(() => ctrl.abort(), remaining);
|
|
495
457
|
const t0 = Date.now();
|
|
496
458
|
let injection = null;
|
|
497
|
-
let
|
|
498
|
-
let winMins = null; // the ~minutes the win note cites — reused verbatim by the insight record
|
|
499
|
-
let steerLabel = null; // "what skalpel's doing" for the statusline
|
|
459
|
+
let steerLabel = null; // "what skalpel's doing" for the statusline — a DESCRIPTION, never a number
|
|
500
460
|
let steerType = null; // raw top-steer type (TELL/SINK/…) — for the feedback loop + session log
|
|
501
461
|
let steerWt = null; // the work-type the steer is about (refine/debug/…)
|
|
502
|
-
let
|
|
462
|
+
let brokeOut = false; // behavioral-only: did expected-waste-ahead drop after last turn's steer (adherence)
|
|
503
463
|
let outcome = "fetch_error";
|
|
504
464
|
try {
|
|
505
465
|
const r = await fetch(`${API}/retrieve`, {
|
|
@@ -546,59 +506,35 @@ async function main() {
|
|
|
546
506
|
"\n\n[skalpel — the user asked for MORE on this trap; expand: name the shape, the count, and the way out in 2-3 sentences.]";
|
|
547
507
|
}
|
|
548
508
|
|
|
549
|
-
// --- n+1 CONFIRMATION:
|
|
550
|
-
// Turn N (a flagged high-waste moment) armed pending with V(N) = the DAG's expected waste
|
|
551
|
-
//
|
|
552
|
-
// moved toward resolved
|
|
553
|
-
//
|
|
509
|
+
// --- n+1 CONFIRMATION: did the user BREAK OUT of last turn's flagged pattern? (behavioral only) ---
|
|
510
|
+
// Turn N (a flagged high-waste moment) armed pending with V(N) = the DAG's expected waste-ahead from
|
|
511
|
+
// where you were. THIS turn's waste-ahead is V(N+1) = j.state_hours. A meaningful DROP means you
|
|
512
|
+
// moved toward "resolved" — a BEHAVIORAL signal that feeds adherence (did acting on the steer pay
|
|
513
|
+
// off). It is NEVER converted into a user-facing "time saved" number: the DAG state values are a
|
|
514
|
+
// server estimate, not a measured clock, so surfacing them as minutes would be fabrication.
|
|
554
515
|
const vNow = typeof j?.state_hours === "number" ? j.state_hours : null;
|
|
555
|
-
const vcNow = typeof j?.state_cost === "number" ? j.state_cost : 0;
|
|
556
516
|
const pending = readPending();
|
|
557
517
|
if (pending && typeof pending.v === "number" && vNow !== null) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
// code credited sub-minute drops and forced caughtMins to 1 via Math.max, so caught ticked up
|
|
564
|
-
// while creditMins rounded to 0 and the visible time didn't budge — the incoherence.)
|
|
565
|
-
const cm = Math.round(savedH * 60);
|
|
566
|
-
if (cm >= 1) {
|
|
567
|
-
bumpStats(savedH, savedC);
|
|
568
|
-
creditMins = cm; // the felt "+Xmin ✓" tick — same value as the catch
|
|
569
|
-
logSessionEvent({ caughtMins: cm }); // caught++ and recap minutes, same number
|
|
570
|
-
dbg(` -> n+1 CREDIT: waste-ahead ${pending.v}h -> ${vNow}h, +${cm}min saved`);
|
|
571
|
-
// REINFORCE GOOD MOVES (the carrot): on a MEANINGFUL break-out (not a trivial drop),
|
|
572
|
-
// hand the model a brief positive-acknowledgment note. Sparse by threshold so praise stays
|
|
573
|
-
// a signal, not noise — a coach that celebrates every micro-step becomes wallpaper too.
|
|
574
|
-
if (savedH >= 0.08) {
|
|
575
|
-
const mins = Math.max(1, Math.round(savedH * 60));
|
|
576
|
-
winMins = mins;
|
|
577
|
-
winNote =
|
|
578
|
-
`[skalpel — the user just broke OUT of a pattern that usually costs them ~${mins}min of rework. ` +
|
|
579
|
-
`If it feels natural (don't force it), briefly acknowledge the good move in one line — ` +
|
|
580
|
-
`e.g. "🔬 skalpel · nice — you skipped the loop you usually get stuck in there" — then continue. ` +
|
|
581
|
-
`Reinforce the behavior, don't lecture. Skip it if the moment doesn't fit.]`;
|
|
582
|
-
}
|
|
583
|
-
} else {
|
|
584
|
-
dbg(
|
|
585
|
-
` -> n+1 no-credit: waste-ahead ${pending.v}h -> ${vNow}h (drop < 1min, not counted)`,
|
|
586
|
-
);
|
|
587
|
-
}
|
|
518
|
+
// >=0.08 in the estimate's units = a real move toward resolved (not estimate jitter). Boolean only.
|
|
519
|
+
brokeOut = pending.v - vNow >= 0.08;
|
|
520
|
+
dbg(
|
|
521
|
+
` -> n+1 ${brokeOut ? "BREAK-OUT" : "no-breakout"}: waste-ahead ${pending.v} -> ${vNow}`,
|
|
522
|
+
);
|
|
588
523
|
writePending({}); // this turn's confirmation is consumed
|
|
589
524
|
}
|
|
590
525
|
// Arm NEXT turn: when the injection fires (a flagged high-waste moment) AND we have a V for this
|
|
591
|
-
// turn, remember it so N+1 can
|
|
526
|
+
// turn, remember it so N+1 can tell whether you broke out.
|
|
592
527
|
if (injection && vNow !== null) {
|
|
593
|
-
writePending({ v: vNow
|
|
528
|
+
writePending({ v: vNow });
|
|
594
529
|
} else if (!pending || vNow !== null) {
|
|
595
530
|
writePending({}); // clear only when we actually had data this turn (don't drop a stale carry)
|
|
596
531
|
}
|
|
597
532
|
|
|
598
|
-
// IMPLICIT ADHERENCE (the real Grammarly loop): now that the
|
|
599
|
-
// whether LAST turn's steer landed — did you act on it (moved off its work-type) and did acting
|
|
600
|
-
//
|
|
601
|
-
|
|
533
|
+
// IMPLICIT ADHERENCE (the real Grammarly loop): now that the break-out check has resolved, record
|
|
534
|
+
// whether LAST turn's steer landed — did you act on it (moved off its work-type) and did acting pay
|
|
535
|
+
// off (a break-out this turn). Accretes per steer-type; the gate above reads it next time. This is
|
|
536
|
+
// categorical revealed-preference data, not a number shown to the user.
|
|
537
|
+
const paid = brokeOut;
|
|
602
538
|
const adh = recordAdherence(prefs, prevSteer, steerWt, paid);
|
|
603
539
|
writePrefs(prefs);
|
|
604
540
|
// ADHERENCE INSIGHT ROW (data, not display — no `display` field): the same judgment, exported
|
|
@@ -626,11 +562,10 @@ async function main() {
|
|
|
626
562
|
record("prompt", outcome, Date.now() - t0);
|
|
627
563
|
}
|
|
628
564
|
|
|
629
|
-
// MUTE is absolute — a shushed session surfaces NOTHING (steer
|
|
630
|
-
//
|
|
565
|
+
// MUTE is absolute — a shushed session surfaces NOTHING (steer or reset). The ack note (a one-time
|
|
566
|
+
// "muted" confirmation) still goes through, since that IS the mute turn's response.
|
|
631
567
|
if (prefs.muted_session) {
|
|
632
568
|
resetNote = null;
|
|
633
|
-
winNote = null;
|
|
634
569
|
injection = null;
|
|
635
570
|
steerLabel = null;
|
|
636
571
|
}
|
|
@@ -638,13 +573,13 @@ async function main() {
|
|
|
638
573
|
// A live cascade reset outranks the server steer for the statusline label (it's the acute one).
|
|
639
574
|
if (resetNote) steerLabel = "resetting a spiral";
|
|
640
575
|
|
|
641
|
-
// Statusline state, written ONCE per turn: what skalpel is doing now
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
576
|
+
// Statusline state, written ONCE per turn: the plain-English label of what skalpel is doing now (a
|
|
577
|
+
// description, never a number), plus the raw steer identity so NEXT turn's feedback phrase can target
|
|
578
|
+
// it. label is null on a silent turn. steerId = the join key minted once per STEER turn (a string),
|
|
579
|
+
// written to BOTH steer.json and the steer's insight row below, so next turn's adherence record can
|
|
580
|
+
// reference exactly this steer.
|
|
646
581
|
const steerId = injection ? String(Date.now()) : null;
|
|
647
|
-
writeSteer(steerLabel,
|
|
582
|
+
writeSteer(steerLabel, steerType, steerWt, steerId);
|
|
648
583
|
if (injection) logSessionEvent({ type: steerType, wt: steerWt }); // feed the end-of-session recap
|
|
649
584
|
|
|
650
585
|
// LOCAL INSIGHT RECORDS (~/.skalpel/insights.ndjson) — one plain-English row per note kind that
|
|
@@ -656,15 +591,6 @@ async function main() {
|
|
|
656
591
|
id: steerId,
|
|
657
592
|
display: steerLabel || "a steer for this spot",
|
|
658
593
|
label: steerLabel,
|
|
659
|
-
mins: creditMins > 0 ? creditMins : null,
|
|
660
|
-
harness,
|
|
661
|
-
session_id: sessionId,
|
|
662
|
-
});
|
|
663
|
-
if (winNote)
|
|
664
|
-
recordInsight({
|
|
665
|
-
kind: "win",
|
|
666
|
-
display: `nice — you broke out of a pattern that usually costs ~${winMins}min`,
|
|
667
|
-
mins: winMins,
|
|
668
594
|
harness,
|
|
669
595
|
session_id: sessionId,
|
|
670
596
|
});
|
|
@@ -685,11 +611,9 @@ async function main() {
|
|
|
685
611
|
|
|
686
612
|
// A just-finished background build leads (one-shot: surface "your graph's ready" the instant the next
|
|
687
613
|
// prompt fires, so the user doesn't have to restart a session to see it), then ackNote (feedback
|
|
688
|
-
// confirmation), the reset interrupt (most acute), the server steer
|
|
614
|
+
// confirmation), the reset interrupt (most acute), and the server steer. Any can fire alone.
|
|
689
615
|
const readyNote = graphReadyNote();
|
|
690
|
-
const additionalContext = [readyNote, ackNote, resetNote,
|
|
691
|
-
.filter(Boolean)
|
|
692
|
-
.join("\n\n");
|
|
616
|
+
const additionalContext = [readyNote, ackNote, resetNote, injection].filter(Boolean).join("\n\n");
|
|
693
617
|
if (additionalContext) {
|
|
694
618
|
process.stdout.write(
|
|
695
619
|
JSON.stringify({
|
package/skalpel-statusline.mjs
CHANGED
|
@@ -1,65 +1,137 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// skalpel statusline —
|
|
3
|
-
// a
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
2
|
+
// skalpel statusline — COUNT-OR-NOTHING. Prints ONLY facts that reproduce from local state, never an
|
|
3
|
+
// estimate dressed up as a measurement. There is no "~Xh saved" and no "+min this turn": those were a
|
|
4
|
+
// SERVER estimate of expected-waste (never a measured clock), so they are gone. What can honestly show:
|
|
5
|
+
// • the live re-ask/interrupt COUNT for THIS session, read straight from the transcript Claude Code
|
|
6
|
+
// hands us on stdin — the exact material `skalpel autopsy` re-derives from disk, a count of things
|
|
7
|
+
// that verifiably happened, or
|
|
8
|
+
// • the current steer's plain-English label (what skalpel is doing now) — a description, not a number.
|
|
9
|
+
// A clean session prints NOTHING. Fail-open: any error → print nothing, exit 0. Never blocks the bar.
|
|
10
|
+
import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
7
11
|
import { homedir } from "node:os";
|
|
8
12
|
import { join } from "node:path";
|
|
9
13
|
|
|
10
|
-
const STATS_PATH = join(homedir(), ".skalpel", "stats.json");
|
|
11
|
-
const STEER_PATH = join(homedir(), ".skalpel", "steer.json");
|
|
12
|
-
|
|
13
|
-
// ANSI — the status bar renders color. Emphasize the WIN (time saved) in green; show the live steer
|
|
14
|
-
// in accent; recede the secondary numbers so the eye lands on saved-time + what skalpel's doing now.
|
|
15
|
-
const GREEN = "\x1b[32m";
|
|
16
14
|
const CYAN = "\x1b[36m";
|
|
17
15
|
const BOLD = "\x1b[1m";
|
|
18
16
|
const DIM = "\x1b[2m";
|
|
19
17
|
const RESET = "\x1b[0m";
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
// Claude Code pipes a JSON status payload on stdin (transcript_path, session_id, …). Read it ONLY when
|
|
20
|
+
// stdin is actually piped — never block on a TTY (manual invocation) where reading fd 0 would hang.
|
|
21
|
+
function readPayload() {
|
|
23
22
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
if (process.stdin.isTTY) return {};
|
|
24
|
+
const raw = readFileSync(0, "utf8");
|
|
25
|
+
if (!raw.trim()) return {};
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
26
28
|
} catch {
|
|
27
|
-
|
|
29
|
+
return {};
|
|
28
30
|
}
|
|
29
|
-
|
|
30
|
-
const sess = finiteOrZero(s.session_hours).toFixed(1);
|
|
31
|
-
const total = finiteOrZero(s.total_hours).toFixed(1);
|
|
32
|
-
const steers = finiteOrZero(s.total_events);
|
|
31
|
+
}
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
let
|
|
33
|
+
// Read only the TAIL of a (possibly large) transcript — the status bar re-renders often, so we never
|
|
34
|
+
// slurp a multi-MB file. Drop the partial first line so JSON.parse only ever sees whole records.
|
|
35
|
+
function tailLines(path, maxBytes) {
|
|
36
|
+
let fd;
|
|
38
37
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
fd = openSync(path, "r");
|
|
39
|
+
const size = fstatSync(fd).size;
|
|
40
|
+
const start = Math.max(0, size - maxBytes);
|
|
41
|
+
const len = size - start;
|
|
42
|
+
if (len <= 0) return [];
|
|
43
|
+
const buf = Buffer.allocUnsafe(len);
|
|
44
|
+
readSync(fd, buf, 0, len, start);
|
|
45
|
+
let text = buf.toString("utf8");
|
|
46
|
+
if (start > 0) {
|
|
47
|
+
const nl = text.indexOf("\n");
|
|
48
|
+
if (nl >= 0) text = text.slice(nl + 1);
|
|
49
|
+
}
|
|
50
|
+
return text.split("\n").filter(Boolean);
|
|
42
51
|
} catch {
|
|
43
|
-
|
|
52
|
+
return [];
|
|
53
|
+
} finally {
|
|
54
|
+
if (fd !== undefined) {
|
|
55
|
+
try {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
} catch {
|
|
58
|
+
/* already closed */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
44
61
|
}
|
|
45
|
-
|
|
46
|
-
// green, right after the wordmark: you SEE it land, then it folds into the growing total.
|
|
47
|
-
// the per-turn tick sits to the RIGHT of the running total: total = the accumulation, tick = what
|
|
48
|
-
// THIS turn just added. Both green (the win).
|
|
49
|
-
const tick = creditMins ? ` · ${BOLD}${GREEN}+${creditMins} min saved this turn${RESET}` : "";
|
|
50
|
-
// Animate the steering label so it reads as LIVE — Claude Code re-invokes the statusline periodically,
|
|
51
|
-
// so a time-derived frame cycles a small spinner. (A single-line status bar can't move text vertically;
|
|
52
|
-
// this is the alive-feeling equivalent.) Only spins while a steer is on the board.
|
|
53
|
-
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
54
|
-
const frame = FRAMES[Math.floor(Date.now() / 120) % FRAMES.length];
|
|
55
|
-
const steering = steerLabel ? ` · ${CYAN}${frame} steering: ${steerLabel}${RESET}` : "";
|
|
62
|
+
}
|
|
56
63
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
64
|
+
// The re-ask signal, counted from disk: over the last few user turns of THIS session's transcript, how
|
|
65
|
+
// many times did the user INTERRUPT ("[Request interrupted by user]") or fire a short CORRECTION ("no",
|
|
66
|
+
// "still wrong", "that's not it"). Mirrors the per-turn hook's own cascade detector so the number the
|
|
67
|
+
// bar shows is the same one the hook acts on — and it is a COUNT of real events, not a time estimate.
|
|
68
|
+
const CORRECTION =
|
|
69
|
+
/^(no\b|nope|that'?s not|not what|still\b|again\b|wrong\b|i said|i meant|wtf|tf\b|why (is|are|does|isn|do)|ugh|c'?mon|cmon|bruh)/i;
|
|
70
|
+
function reaskCount(transcriptPath) {
|
|
71
|
+
if (!transcriptPath) return 0;
|
|
72
|
+
const lines = tailLines(transcriptPath, 256 * 1024);
|
|
73
|
+
if (!lines.length) return 0;
|
|
74
|
+
let signals = 0;
|
|
75
|
+
let userTurns = 0;
|
|
76
|
+
for (let i = lines.length - 1; i >= 0 && userTurns < 6; i--) {
|
|
77
|
+
let e;
|
|
78
|
+
try {
|
|
79
|
+
e = JSON.parse(lines[i]);
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const raw = e.content ?? e.message?.content;
|
|
84
|
+
const text =
|
|
85
|
+
typeof raw === "string"
|
|
86
|
+
? raw
|
|
87
|
+
: Array.isArray(raw)
|
|
88
|
+
? raw
|
|
89
|
+
.filter((b) => b?.type === "text")
|
|
90
|
+
.map((b) => b.text)
|
|
91
|
+
.join(" ")
|
|
92
|
+
: "";
|
|
93
|
+
if (/\[Request interrupted by user\]/i.test(text)) signals++;
|
|
94
|
+
if (e.type === "user" || e.role === "user") {
|
|
95
|
+
userTurns++;
|
|
96
|
+
if (CORRECTION.test(text.trim())) signals++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return signals;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The current steer's label — the plain-English "what skalpel is doing" for the latest turn (null on a
|
|
103
|
+
// silent turn, since the hook overwrites it to null when nothing fired). A DESCRIPTION, not a number. We
|
|
104
|
+
// read ONLY `label`; the old `credit` minutes field is not read here and is no longer written at all.
|
|
105
|
+
function steerLabel() {
|
|
106
|
+
try {
|
|
107
|
+
const st = JSON.parse(readFileSync(join(homedir(), ".skalpel", "steer.json"), "utf8"));
|
|
108
|
+
const label = st && typeof st === "object" ? st.label : null;
|
|
109
|
+
return typeof label === "string" && label.trim() ? label.trim() : null;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function main() {
|
|
116
|
+
const payload = readPayload();
|
|
117
|
+
|
|
118
|
+
// Acute + reproducible: the user has re-asked / interrupted repeatedly this stretch. Surface the count
|
|
119
|
+
// and point at the receipts — every figure in `skalpel autopsy` re-derives from these same local logs.
|
|
120
|
+
const n = reaskCount(payload.transcript_path);
|
|
121
|
+
if (n >= 2) {
|
|
122
|
+
process.stdout.write(
|
|
123
|
+
`${BOLD}skalpel${RESET} · ${CYAN}${n} re-asks/interrupts this stretch${RESET} · ${DIM}run: skalpel autopsy${RESET}`,
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Otherwise, if a steer is live, name what it's doing — a description, no number. Else: print NOTHING.
|
|
129
|
+
const label = steerLabel();
|
|
130
|
+
if (label) {
|
|
131
|
+
process.stdout.write(`${BOLD}skalpel${RESET} · ${CYAN}${label}${RESET}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Clean session → COUNT-OR-NOTHING resolves to nothing. Emit no bytes.
|
|
63
135
|
}
|
|
64
136
|
|
|
65
137
|
main();
|