skalpel 4.0.20 → 4.0.21

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/auth.mjs CHANGED
@@ -131,11 +131,21 @@ function persistTokens(auth, t) {
131
131
  }
132
132
  }
133
133
 
134
+ // Returns { uid, token } on success. On failure it distinguishes two cases the caller MUST treat
135
+ // differently, via an `expired` flag:
136
+ // • not signed in yet (no saved session) -> { uid:null, token:null } (expired absent/false)
137
+ // • signed in, but a token REFRESH FAILED -> { uid:null, token:null, expired:true }
138
+ // The hook uses `expired` to surface a visible "session expired — run `skalpel login`" note instead
139
+ // of silently degrading to the anonymous graph (which reads as "skalpel found nothing").
134
140
  export async function identity() {
135
141
  if (process.env.SKALPEL_USER) return { uid: process.env.SKALPEL_USER, token: null };
136
142
 
137
143
  const auth = loadCliAuth();
138
- if (!auth) return { uid: null, token: null };
144
+ if (!auth) return { uid: null, token: null }; // genuinely not signed in — stay silent
145
+
146
+ // Whether we attempted a refresh and it failed. Only set true on a real refresh failure of a saved
147
+ // session, never on "no session" — so the caller can tell an expiry apart from a fresh install.
148
+ let refreshFailed = false;
139
149
 
140
150
  // Google is the account plane (from `skalpel login`). Send the Google id_token; the server verifies
141
151
  // it (aud-pinned) and keys the graph by the verified email. Refresh through AWS (/oauth/refresh) so
@@ -157,7 +167,8 @@ export async function identity() {
157
167
  /* never block on persist */
158
168
  }
159
169
  } else {
160
- idt = null; // refresh failed — fall through to the Cognito bridge
170
+ idt = null; // refresh failed — fall through to the Cognito bridge if one exists
171
+ refreshFailed = true;
161
172
  }
162
173
  }
163
174
  if (idt) {
@@ -167,17 +178,22 @@ export async function identity() {
167
178
  }
168
179
 
169
180
  // The hosted graph verifies the ACCESS token (Cognito /oauth2/userInfo -> email; an id_token is
170
- // rejected there). Send the access token; the server keys the graph by the verified email.
181
+ // rejected there). Send the access token; the server keys the graph by the verified email. With no
182
+ // Cognito bridge to fall back on (e.g. a Google-only session whose refresh just failed), the
183
+ // signed-in session is dead — report the expiry rather than dereferencing a missing `auth.cognito`.
184
+ if (!auth.cognito || (!auth.cognito.access_token && !auth.cognito.refresh_token)) {
185
+ return { uid: null, token: null, expired: refreshFailed };
186
+ }
171
187
  let access = auth.cognito.access_token;
172
188
  const claims = decodeJwtPayload(access);
173
189
  const expiresAt = toEpochSeconds(auth.cognito.expires_at) ?? claims?.exp ?? null;
174
190
  if (!access || (expiresAt !== null && expiresAt - Math.floor(Date.now() / 1000) < 60)) {
175
191
  const t = await refreshTokens(auth.cognito.refresh_token);
176
- if (!t) return { uid: null, token: null };
192
+ if (!t) return { uid: null, token: null, expired: true }; // signed in, but refresh FAILED
177
193
  access = t.access_token;
178
194
  persistTokens(auth, t);
179
195
  }
180
196
 
181
197
  const uid = decodeJwtPayload(access)?.sub ?? auth.subject?.user_id ?? null;
182
- return uid ? { uid, token: access } : { uid: null, token: null };
198
+ return uid ? { uid, token: access } : { uid: null, token: null, expired: refreshFailed };
183
199
  }
package/bootstrap.mjs CHANGED
@@ -23,6 +23,7 @@ import { pathToFileURL } from "node:url";
23
23
  import { gzipSync } from "node:zlib";
24
24
  import { identity } from "./auth.mjs";
25
25
  import { recordInsight } from "./insights.mjs";
26
+ import { isGenuineUserTurn } from "./session-turns.mjs";
26
27
 
27
28
  const DIR = join(homedir(), ".skalpel");
28
29
  const STATE_PATH = join(DIR, "bootstrap.json");
@@ -127,7 +128,9 @@ async function userTurnCount(path, cap = MIN_USER_TURNS) {
127
128
  } catch {
128
129
  continue;
129
130
  }
130
- if (event?.type === "user" || event?.role === "user") {
131
+ // Shared genuine-turn test excludes the tool_result rows Claude Code logs as role:"user",
132
+ // exactly like skalpel-setup's scanHistory, so both counting paths agree on "found N".
133
+ if (isGenuineUserTurn(event)) {
131
134
  count += 1;
132
135
  if (count >= cap) {
133
136
  lines.close();
@@ -266,7 +269,8 @@ export function normalizeJobStatus(status = {}) {
266
269
  const terminal = status.terminal === true;
267
270
  const requiresReplay = status.requires_replay === true;
268
271
  const failed = state === "error" || state === "failed" || (terminal && requiresReplay);
269
- const complete = state === "done";
272
+ const isEmpty = state === "empty"; // zero parseable sessions — terminal, honest, NOT a failure
273
+ const complete = state === "done" || isEmpty;
270
274
  return {
271
275
  state,
272
276
  done,
@@ -277,6 +281,7 @@ export function normalizeJobStatus(status = {}) {
277
281
  requiresReplay,
278
282
  complete,
279
283
  failed,
284
+ empty: isEmpty,
280
285
  sessions: count(status.n_sessions ?? done),
281
286
  message: failed
282
287
  ? String(
package/install.mjs CHANGED
@@ -56,6 +56,7 @@ function stageHookRuntime() {
56
56
  copyFileSync(join(PKG_DIR, "reveal.mjs"), join(HOOKS_DIR, "reveal.mjs")); // both hooks import ./reveal.mjs
57
57
  copyFileSync(join(PKG_DIR, "transcript.mjs"), join(HOOKS_DIR, "transcript.mjs")); // skalpel-hook.mjs + skalpel-statusline.mjs import ./transcript.mjs (tailLines)
58
58
  copyFileSync(join(PKG_DIR, "skalpel-hook.mjs"), HOOK_FILE);
59
+ copyFileSync(join(PKG_DIR, "transcript.mjs"), join(HOOKS_DIR, "transcript.mjs")); // skalpel-hook imports ./transcript.mjs (tailLines)
59
60
  copyFileSync(join(PKG_DIR, "skalpel-hook-session.mjs"), SESSION_FILE);
60
61
  copyFileSync(join(PKG_DIR, "incremental-ingest.mjs"), join(HOOKS_DIR, "incremental-ingest.mjs"));
61
62
  copyFileSync(join(PKG_DIR, "skalpel-hook-session-end.mjs"), SESSION_END_FILE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.20",
3
+ "version": "4.0.21",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
package/reveal.mjs CHANGED
@@ -24,7 +24,10 @@ export function graphReadyNote() {
24
24
  try {
25
25
  writeFileSync(p, JSON.stringify({ ready: false })); // one-shot — clear so it never repeats
26
26
  } catch {
27
- /* if we can't clear it, better to drop the reveal than repeat it but proceed once */
27
+ // Couldn't clear the marker (e.g. a root-owned ~/.skalpel from a past `sudo` install). If we
28
+ // proceeded, the reveal would re-inject EVERY turn forever. Drop it instead — better to miss
29
+ // this one-shot once than to nag on every prompt.
30
+ return null;
28
31
  }
29
32
 
30
33
  const h = (m.report && m.report.headline) || {};
@@ -0,0 +1,24 @@
1
+ // session-turns.mjs — the SINGLE definition of "a genuine human user turn", shared by every path
2
+ // that counts sessions (skalpel-setup's scanHistory + bootstrap's first-run scan). Claude Code logs
3
+ // every tool RESULT back as a role:"user" message (content = [{type:"tool_result"}]); counting those
4
+ // as user turns is the bug that classifies internal tool traffic as real intents and inflates
5
+ // "found N sessions" so the client promises history the server then drops (empty graphs for
6
+ // quiet/agentic users). Mirrors the server's engine._user_turn_count EXACTLY — one definition,
7
+ // imported everywhere so the counting paths can never drift. See docs/SESSION-COUNTING.md.
8
+ //
9
+ // NOTE: this module is imported ONLY by the package-dir runners (skalpel-setup.mjs, bootstrap.mjs),
10
+ // NOT by any hook staged into ~/.skalpel/hooks — so install.mjs deliberately does NOT stage it.
11
+ const _HUMAN_PROMPT_SOURCES = new Set(["typed", "queued", "suggestion_accepted"]);
12
+
13
+ export function isGenuineUserTurn(e) {
14
+ if (!(e?.type === "user" || e?.role === "user")) return false;
15
+ const ps = e?.promptSource;
16
+ if (ps != null) return _HUMAN_PROMPT_SOURCES.has(ps); // newer CLI: trust the source label
17
+ // older/current builds omit promptSource — a row is a tool RESULT (not a query) when its content
18
+ // is a block list carrying a tool_result. Everything else that's role:user is a genuine query.
19
+ const c = e?.message?.content;
20
+ if (Array.isArray(c) && c.some((b) => b && typeof b === "object" && b.type === "tool_result")) {
21
+ return false;
22
+ }
23
+ return true;
24
+ }
@@ -84,6 +84,32 @@ function resetStats() {
84
84
  }
85
85
  }
86
86
 
87
+ // A background graph build that FAILED/stalled (skalpel-setup backgroundBuild) drops
88
+ // ~/.skalpel/build-stalled.json. Surface it ONCE at the next session start so the broken "I'll notify
89
+ // you the moment it's ready" promise becomes visible ("re-run `skalpel`"), then clear it. If the
90
+ // marker can't be cleared, drop the note rather than repeat it every session (same one-shot discipline
91
+ // as the reveal — better to miss it once than nag forever).
92
+ const BUILD_STALLED_PATH = join(homedir(), ".skalpel", "build-stalled.json");
93
+ function buildStalledNote() {
94
+ let m;
95
+ try {
96
+ m = JSON.parse(readFileSync(BUILD_STALLED_PATH, "utf8"));
97
+ } catch {
98
+ return null; // no marker pending
99
+ }
100
+ if (!m || m.stalled !== true) return null;
101
+ try {
102
+ writeFileSync(BUILD_STALLED_PATH, JSON.stringify({ stalled: false })); // one-shot — clear it
103
+ } catch {
104
+ return null; // can't clear it (root-owned dir?) → drop rather than repeat every session
105
+ }
106
+ return (
107
+ "[skalpel — the background graph build did NOT finish (it stalled or timed out), so the promised " +
108
+ '"your graph is ready" never arrived. Tell the user in ONE short line: "🔬 skalpel · your graph ' +
109
+ 'build stalled — re-run `skalpel` to finish it." Then continue normally.]'
110
+ );
111
+ }
112
+
87
113
  // Config precedence: env > ~/.skalpel/client.json (written by `skalpel-prosumer setup`) > default.
88
114
  // Identity: the uid from the Google sign-in (~/.skalpel/auth.json); SKALPEL_USER overrides for dev.
89
115
  function fileCfg() {
@@ -151,10 +177,15 @@ async function main() {
151
177
  record("session", outcome, Date.now() - t0);
152
178
  }
153
179
 
154
- // A just-finished background build leads (the async payoff — surface it the instant it's ready),
155
- // then last session's recap, then the standing profile. Any can fire alone.
180
+ // A just-finished background build leads (the async payoff — surface it the instant it's ready);
181
+ // else a build that STALLED surfaces its one-time "re-run `skalpel`" note (the broken-promise trace).
182
+ // Then last session's recap, then the standing profile. Any can fire alone.
156
183
  const readyNote = graphReadyNote();
157
- const additionalContext = [readyNote, recapNote, context].filter(Boolean).join("\n\n");
184
+ const stalled = buildStalledNote(); // reads + clears the one-shot stall marker (no-op when none)
185
+ const stalledNote = readyNote ? null : stalled; // a just-ready graph supersedes a stale stall note
186
+ const additionalContext = [readyNote, stalledNote, recapNote, context]
187
+ .filter(Boolean)
188
+ .join("\n\n");
158
189
 
159
190
  // LOCAL INSIGHT RECORDS (~/.skalpel/insights.ndjson) — plain-English rows for the TUI, one per
160
191
  // note that fired. recordInsight swallows everything, so these never affect the hook output.
package/skalpel-hook.mjs CHANGED
@@ -246,21 +246,31 @@ function parseControl(q) {
246
246
  const t = (q || "").trim().toLowerCase();
247
247
  if (t.length > 60) return null; // a real coding prompt, not a reaction
248
248
  const mentionsUs = /\bskalpel\b/.test(t);
249
+ // A "pure control reaction" = the WHOLE prompt IS the control phrase (bare, aside from a leading
250
+ // politeness word or trailing punctuation) — "mute", "shut up", "fewer like this". Anchoring to the
251
+ // whole string is what stops a session-state change firing from a phrase buried in a REAL
252
+ // instruction like "disable the cache", "turn off dark mode", or "stop this loop from running".
253
+ // Any state-changing action (mute/unmute/fewer) now needs an explicit "skalpel" mention OR a pure
254
+ // reaction — never a bare substring. (The old `t.length < 24` heuristic matched that substring and
255
+ // silently muted every steer for the session; "fewer" had no guard at all.)
256
+ const pure = (body) => new RegExp(`^(?:please |hey |ok )?(?:${body})[\\s.!]*$`).test(t);
249
257
  // Explicit feedback is the LIGHT layer on top of the implicit adherence loop — natural-language
250
258
  // control the user can type when they want to steer skalpel directly. Mute is the important one.
251
259
  if (
252
260
  /\b(mute|silence|shut ?up|turn off|disable|stop steering|quiet)\b/.test(t) &&
253
- (mentionsUs || t.length < 24)
261
+ (mentionsUs || pure("mute|silence|shut ?up|turn off|disable|stop steering|quiet"))
254
262
  )
255
263
  return { action: "mute" };
256
264
  if (
257
265
  /\bunmute|re-?enable|turn (it )?back on|resume steering\b/.test(t) &&
258
- (mentionsUs || t.length < 24)
266
+ (mentionsUs || pure("unmute|re-?enable|turn (?:it )?back on|resume steering"))
259
267
  )
260
268
  return { action: "unmute" };
261
269
  if (
262
- /\b(fewer|less|stop) (of )?(these|this|that|like this|steers?)\b/.test(t) ||
263
- /\bfewer like this\b/.test(t)
270
+ (/\b(fewer|less|stop) (of )?(these|this|that|like this|steers?)\b/.test(t) ||
271
+ /\bfewer like this\b/.test(t)) &&
272
+ (mentionsUs ||
273
+ pure("(?:fewer|less|stop) (?:of )?(?:these|this|that|like this|steers?)|fewer like this"))
264
274
  )
265
275
  return { action: "fewer" };
266
276
  if (/\b(tell me more|more detail|expand|go deeper|more on this|explain the trap)\b/.test(t))
@@ -402,6 +412,46 @@ function dbg(msg) {
402
412
  }
403
413
  }
404
414
 
415
+ // AUTH EXPIRY — the LOUD, once-per-expiry note. auth.identity() reports `expired:true` when the user
416
+ // IS signed in but a token refresh FAILED. Left unsurfaced, the hook silently queries the graph as
417
+ // `anon` forever — personalization evaporates and it reads as "skalpel found nothing". We surface a
418
+ // single visible "session expired — run `skalpel login`" note, then remember we showed it so we don't
419
+ // nag every turn; a later working identity clears the flag so a FUTURE expiry notifies again.
420
+ const AUTH_NOTICE_PATH = join(homedir(), ".skalpel", "auth-notice.json");
421
+ function authExpiredNoteOnce() {
422
+ try {
423
+ if (JSON.parse(readFileSync(AUTH_NOTICE_PATH, "utf8"))?.shown === true) return null; // already told them
424
+ } catch {
425
+ /* no notice file yet — this is the first expired turn */
426
+ }
427
+ try {
428
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
429
+ const tmp = `${AUTH_NOTICE_PATH}.tmp`;
430
+ writeFileSync(tmp, JSON.stringify({ shown: true, ts: Date.now() }));
431
+ renameSync(tmp, AUTH_NOTICE_PATH);
432
+ } catch {
433
+ // Can't record that we showed it → DROP the note rather than risk repeating it every turn
434
+ // (same discipline as the one-shot reveal). Better to miss it once than nag forever.
435
+ return null;
436
+ }
437
+ return (
438
+ "[skalpel — the user's saved sign-in EXPIRED and the token refresh FAILED, so skalpel can't " +
439
+ "personalize this session (it would otherwise silently fall back to an empty anonymous graph). " +
440
+ 'Tell them in ONE short line: "🔬 skalpel · session expired — run `skalpel login` to reconnect ' +
441
+ 'your graph." Then continue with whatever they actually asked.]'
442
+ );
443
+ }
444
+ function clearAuthExpiredNotice() {
445
+ try {
446
+ // Only rewrite when it was actually set, to avoid a needless write on every healthy turn.
447
+ if (JSON.parse(readFileSync(AUTH_NOTICE_PATH, "utf8"))?.shown === true) {
448
+ writeFileSync(AUTH_NOTICE_PATH, JSON.stringify({ shown: false }));
449
+ }
450
+ } catch {
451
+ /* no notice file — nothing to clear */
452
+ }
453
+ }
454
+
405
455
  async function main() {
406
456
  let payload = {};
407
457
  try {
@@ -481,126 +531,141 @@ async function main() {
481
531
 
482
532
  // identity from the Google sign-in (refreshed if near expiry); the token authenticates the call so
483
533
  // the server keys the graph by the VERIFIED uid. No token (dev/not signed in) -> server falls back.
484
- const { uid, token } = await identity();
534
+ const { uid, token, expired } = await identity();
485
535
  const user = uid || CFG.user || "anon";
486
536
  const headers = { "content-type": "application/json" };
487
537
  if (token) headers.authorization = `Bearer ${token}`;
488
538
 
489
- const remaining = deadline - Date.now();
490
- if (remaining <= 100) {
491
- dbg(" -> budget spent by auth, skipping retrieve");
492
- record("prompt", "budget_spent", DEADLINE_MS);
493
- return;
539
+ // AUTH EXPIRY (loud, once): signed in but the token refresh FAILED and there's no dev override, so
540
+ // `user` would fall to the anonymous graph. Surface the one-time "session expired" note and SKIP the
541
+ // pointless anon /retrieve a visible nudge to re-login beats a silent "skalpel found nothing".
542
+ let authNote = null;
543
+ if (expired && !uid && !CFG.user) {
544
+ authNote = authExpiredNoteOnce();
545
+ dbg(" -> auth expired (refresh failed); surfacing login note, skipping anon retrieve");
546
+ } else if (uid || CFG.user) {
547
+ clearAuthExpiredNotice(); // a working identity resets the one-shot so a future expiry notifies again
494
548
  }
495
549
 
496
- const ctrl = new AbortController();
497
- const timer = setTimeout(() => ctrl.abort(), remaining);
498
- const t0 = Date.now();
550
+ // Declared in main scope so the note-assembly at the bottom still sees them when the retrieve is skipped.
499
551
  let injection = null;
500
552
  let steerLabel = null; // "what skalpel's doing" for the statusline — a DESCRIPTION, never a number
501
553
  let steerType = null; // raw top-steer type (TELL/SINK/…) — for the feedback loop + session log
502
554
  let steerWt = null; // the work-type the steer is about (refine/debug/…)
503
555
  let brokeOut = false; // behavioral-only: did expected-waste-ahead drop after last turn's steer (adherence)
504
- let outcome = "fetch_error";
505
- try {
506
- const r = await fetch(`${API}/retrieve`, {
507
- method: "POST",
508
- headers,
509
- // `recent` = the last few work-type classes THIS session (the trajectory) — lets the server fire
510
- // the discriminative TELL ("you're on refine→refine→refine, the shape that goes bad 8×").
511
- // `surface: "panel"` only for embedded TUI spawns (the TUI renders insights in its own panel,
512
- // so the server keeps the steering but drops every "emit a 🔬 line" instruction). The key is
513
- // omitted entirely otherwise — byte-stable body for non-embedded users.
514
- body: JSON.stringify({
515
- user_id: user,
516
- query,
517
- recent: readTraj(),
518
- ...(process.env.SKALPEL_EMBEDDED === "1" ? { surface: "panel" } : {}),
519
- }),
520
- signal: ctrl.signal,
521
- });
522
- if (r.ok) {
523
- const j = await r.json();
524
- injection = j?.injection;
525
- outcome = injection ? "inject" : "silent";
526
- steerLabel = j?.steer_label; // surfaced on the statusline (written once below, with any credit)
527
- steerType = j?.steer_type || null;
528
- steerWt = j?.steer_wt || (j?.entry ? String(j.entry).split("/")[0] : null);
529
- if (j?.entry) pushTraj(String(j.entry).split("/")[0]); // remember this turn's work-type
530
- dbg(` -> sim=${j?.top_sim ?? "?"} ${injection ? "INJECT" : "silent"}`);
531
-
532
- // readSteer() still holds the PRIOR turn's steer (we overwrite it below) — used after the n+1
533
- // credit resolves to record adherence (did you act on it, and did acting pay off).
534
- const prevSteer = readSteer();
535
- // HONOR prefs + adherence GATE: mute / per-trap "fewer" / a type you consistently ignore all
536
- // suppress the steer; "more" expands it.
537
- const key = steerType ? (steerWt ? `${steerType}:${steerWt}` : steerType) : null;
538
- const ignored = steerType && ignoredByAdherence(prefs, steerType);
539
- if (prefs.muted_session || (key && prefs.suppress[key]) || ignored) {
540
- injection = null;
541
- steerLabel = null;
542
- dbg(
543
- ` -> suppressed (${prefs.muted_session ? "muted" : ignored ? "ignored-type" : "fewer"}) ${key || ""}`,
544
- );
545
- } else if (key && prefs.verbose[key] && injection) {
546
- injection +=
547
- "\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.]";
548
- }
549
556
 
550
- // --- n+1 CONFIRMATION: did the user BREAK OUT of last turn's flagged pattern? (behavioral only) ---
551
- // Turn N (a flagged high-waste moment) armed pending with V(N) = the DAG's expected waste-ahead from
552
- // where you were. THIS turn's waste-ahead is V(N+1) = j.state_hours. A meaningful DROP means you
553
- // moved toward "resolved" a BEHAVIORAL signal that feeds adherence (did acting on the steer pay
554
- // off). It is NEVER converted into a user-facing "time saved" number: the DAG state values are a
555
- // server estimate, not a measured clock, so surfacing them as minutes would be fabrication.
556
- const vNow = typeof j?.state_hours === "number" ? j.state_hours : null;
557
- const pending = readPending();
558
- if (pending && typeof pending.v === "number" && vNow !== null) {
559
- // >=0.08 in the estimate's units = a real move toward resolved (not estimate jitter). Boolean only.
560
- brokeOut = pending.v - vNow >= 0.08;
561
- dbg(
562
- ` -> n+1 ${brokeOut ? "BREAK-OUT" : "no-breakout"}: waste-ahead ${pending.v} -> ${vNow}`,
563
- );
564
- writePending({}); // this turn's confirmation is consumed
565
- }
566
- // Arm NEXT turn: when the injection fires (a flagged high-waste moment) AND we have a V for this
567
- // turn, remember it so N+1 can tell whether you broke out.
568
- if (injection && vNow !== null) {
569
- writePending({ v: vNow });
570
- } else if (!pending || vNow !== null) {
571
- writePending({}); // clear only when we actually had data this turn (don't drop a stale carry)
572
- }
557
+ if (!authNote) {
558
+ const remaining = deadline - Date.now();
559
+ if (remaining <= 100) {
560
+ dbg(" -> budget spent by auth, skipping retrieve");
561
+ record("prompt", "budget_spent", DEADLINE_MS);
562
+ return;
563
+ }
573
564
 
574
- // IMPLICIT ADHERENCE (the real Grammarly loop): now that the break-out check has resolved, record
575
- // whether LAST turn's steer landed — did you act on it (moved off its work-type) and did acting pay
576
- // off (a break-out this turn). Accretes per steer-type; the gate above reads it next time. This is
577
- // categorical revealed-preference data, not a number shown to the user.
578
- const paid = brokeOut;
579
- const adh = recordAdherence(prefs, prevSteer, steerWt, paid);
580
- writePrefs(prefs);
581
- // ADHERENCE INSIGHT ROW (data, not display — no `display` field): the same judgment, exported
582
- // for the TUI's rail, joined to the prior steer by its minted id. followed = the n+1 credit
583
- // landed; ignored = you stayed on the very work-type the steer pushed you off AND no credit;
584
- // anything else (no prior steer / indeterminate turn) writes nothing.
585
- const adhOutcome = paid ? "followed" : adh && prevSteer.wt && !adh.acted ? "ignored" : null;
586
- if (prevSteer?.id && adhOutcome)
587
- recordInsight({
588
- kind: "adherence",
589
- ref: prevSteer.id,
590
- outcome: adhOutcome,
591
- harness,
592
- session_id: sessionId,
593
- });
594
- } else {
595
- outcome = "server_error";
596
- dbg(` -> server ${r.status}`);
565
+ const ctrl = new AbortController();
566
+ const timer = setTimeout(() => ctrl.abort(), remaining);
567
+ const t0 = Date.now();
568
+ let outcome = "fetch_error";
569
+ try {
570
+ const r = await fetch(`${API}/retrieve`, {
571
+ method: "POST",
572
+ headers,
573
+ // `recent` = the last few work-type classes THIS session (the trajectory) lets the server fire
574
+ // the discriminative TELL ("you're on refine→refine→refine, the shape that goes bad 8×").
575
+ // `surface: "panel"` only for embedded TUI spawns (the TUI renders insights in its own panel,
576
+ // so the server keeps the steering but drops every "emit a 🔬 line" instruction). The key is
577
+ // omitted entirely otherwise — byte-stable body for non-embedded users.
578
+ body: JSON.stringify({
579
+ user_id: user,
580
+ query,
581
+ recent: readTraj(),
582
+ ...(process.env.SKALPEL_EMBEDDED === "1" ? { surface: "panel" } : {}),
583
+ }),
584
+ signal: ctrl.signal,
585
+ });
586
+ if (r.ok) {
587
+ const j = await r.json();
588
+ injection = j?.injection;
589
+ outcome = injection ? "inject" : "silent";
590
+ steerLabel = j?.steer_label; // surfaced on the statusline (written once below, with any credit)
591
+ steerType = j?.steer_type || null;
592
+ steerWt = j?.steer_wt || (j?.entry ? String(j.entry).split("/")[0] : null);
593
+ if (j?.entry) pushTraj(String(j.entry).split("/")[0]); // remember this turn's work-type
594
+ dbg(` -> sim=${j?.top_sim ?? "?"} ${injection ? "INJECT" : "silent"}`);
595
+
596
+ // readSteer() still holds the PRIOR turn's steer (we overwrite it below) — used after the n+1
597
+ // credit resolves to record adherence (did you act on it, and did acting pay off).
598
+ const prevSteer = readSteer();
599
+ // HONOR prefs + adherence GATE: mute / per-trap "fewer" / a type you consistently ignore all
600
+ // suppress the steer; "more" expands it.
601
+ const key = steerType ? (steerWt ? `${steerType}:${steerWt}` : steerType) : null;
602
+ const ignored = steerType && ignoredByAdherence(prefs, steerType);
603
+ if (prefs.muted_session || (key && prefs.suppress[key]) || ignored) {
604
+ injection = null;
605
+ steerLabel = null;
606
+ dbg(
607
+ ` -> suppressed (${prefs.muted_session ? "muted" : ignored ? "ignored-type" : "fewer"}) ${key || ""}`,
608
+ );
609
+ } else if (key && prefs.verbose[key] && injection) {
610
+ injection +=
611
+ "\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.]";
612
+ }
613
+
614
+ // --- n+1 CONFIRMATION: did the user BREAK OUT of last turn's flagged pattern? (behavioral only) ---
615
+ // Turn N (a flagged high-waste moment) armed pending with V(N) = the DAG's expected waste-ahead from
616
+ // where you were. THIS turn's waste-ahead is V(N+1) = j.state_hours. A meaningful DROP means you
617
+ // moved toward "resolved" — a BEHAVIORAL signal that feeds adherence (did acting on the steer pay
618
+ // off). It is NEVER converted into a user-facing "time saved" number: the DAG state values are a
619
+ // server estimate, not a measured clock, so surfacing them as minutes would be fabrication.
620
+ const vNow = typeof j?.state_hours === "number" ? j.state_hours : null;
621
+ const pending = readPending();
622
+ if (pending && typeof pending.v === "number" && vNow !== null) {
623
+ // >=0.08 in the estimate's units = a real move toward resolved (not estimate jitter). Boolean only.
624
+ brokeOut = pending.v - vNow >= 0.08;
625
+ dbg(
626
+ ` -> n+1 ${brokeOut ? "BREAK-OUT" : "no-breakout"}: waste-ahead ${pending.v} -> ${vNow}`,
627
+ );
628
+ writePending({}); // this turn's confirmation is consumed
629
+ }
630
+ // Arm NEXT turn: when the injection fires (a flagged high-waste moment) AND we have a V for this
631
+ // turn, remember it so N+1 can tell whether you broke out.
632
+ if (injection && vNow !== null) {
633
+ writePending({ v: vNow });
634
+ } else if (!pending || vNow !== null) {
635
+ writePending({}); // clear only when we actually had data this turn (don't drop a stale carry)
636
+ }
637
+
638
+ // IMPLICIT ADHERENCE (the real Grammarly loop): now that the break-out check has resolved, record
639
+ // whether LAST turn's steer landed — did you act on it (moved off its work-type) and did acting pay
640
+ // off (a break-out this turn). Accretes per steer-type; the gate above reads it next time. This is
641
+ // categorical revealed-preference data, not a number shown to the user.
642
+ const paid = brokeOut;
643
+ const adh = recordAdherence(prefs, prevSteer, steerWt, paid);
644
+ writePrefs(prefs);
645
+ // ADHERENCE INSIGHT ROW (data, not display — no `display` field): the same judgment, exported
646
+ // for the TUI's rail, joined to the prior steer by its minted id. followed = the n+1 credit
647
+ // landed; ignored = you stayed on the very work-type the steer pushed you off AND no credit;
648
+ // anything else (no prior steer / indeterminate turn) writes nothing.
649
+ const adhOutcome = paid ? "followed" : adh && prevSteer.wt && !adh.acted ? "ignored" : null;
650
+ if (prevSteer?.id && adhOutcome)
651
+ recordInsight({
652
+ kind: "adherence",
653
+ ref: prevSteer.id,
654
+ outcome: adhOutcome,
655
+ harness,
656
+ session_id: sessionId,
657
+ });
658
+ } else {
659
+ outcome = "server_error";
660
+ dbg(` -> server ${r.status}`);
661
+ }
662
+ } catch (e) {
663
+ outcome = e?.name === "AbortError" ? "abort" : "fetch_error";
664
+ dbg(` -> fetch failed: ${String(e).slice(0, 60)}`);
665
+ } finally {
666
+ clearTimeout(timer);
667
+ record("prompt", outcome, Date.now() - t0);
597
668
  }
598
- } catch (e) {
599
- outcome = e?.name === "AbortError" ? "abort" : "fetch_error";
600
- dbg(` -> fetch failed: ${String(e).slice(0, 60)}`);
601
- } finally {
602
- clearTimeout(timer);
603
- record("prompt", outcome, Date.now() - t0);
604
669
  }
605
670
 
606
671
  // MUTE is absolute — a shushed session surfaces NOTHING (steer or reset). The ack note (a one-time
@@ -651,10 +716,13 @@ async function main() {
651
716
  });
652
717
 
653
718
  // A just-finished background build leads (one-shot: surface "your graph's ready" the instant the next
654
- // prompt fires, so the user doesn't have to restart a session to see it), then ackNote (feedback
655
- // confirmation), the reset interrupt (most acute), and the server steer. Any can fire alone.
719
+ // prompt fires, so the user doesn't have to restart a session to see it), then the one-time auth-expiry
720
+ // note, then ackNote (feedback confirmation), the reset interrupt (most acute), and the server steer.
721
+ // Any can fire alone.
656
722
  const readyNote = graphReadyNote();
657
- const additionalContext = [readyNote, ackNote, resetNote, injection].filter(Boolean).join("\n\n");
723
+ const additionalContext = [readyNote, authNote, ackNote, resetNote, injection]
724
+ .filter(Boolean)
725
+ .join("\n\n");
658
726
  if (additionalContext) {
659
727
  process.stdout.write(
660
728
  JSON.stringify({
package/skalpel-setup.mjs CHANGED
@@ -21,6 +21,7 @@ import { createInterface, emitKeypressEvents } from "node:readline";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { spawn, spawnSync } from "node:child_process";
23
23
  import { identity as authIdentity, cliAuthPath } from "./auth.mjs";
24
+ import { isGenuineUserTurn } from "./session-turns.mjs";
24
25
 
25
26
  // CLI (the `skalpel` bin): `skalpel setup [--api https://graph.skalpel.ai]
26
27
  // [--user <id>]`, plus `skalpel uninstall` and `skalpel login`. A bare invocation
@@ -437,26 +438,10 @@ async function reportLiveAndLaunch(message) {
437
438
  // arbitrary "cap to 100" that could upload fragments and MISS the real sessions.
438
439
  const MIN_USER_TURNS = Math.max(1, Number.parseInt(process.env.SKALPEL_MIN_USER_TURNS || "5", 10));
439
440
 
440
- // USER QUERIES ONLY. Claude Code logs every tool RESULT back as a role:"user" message
441
- // (content = [{type:"tool_result"}]). Counting those as "user turns" is the bug that classified
442
- // internal tool traffic as real intents and inflated "found N" so the client promised sessions the
443
- // server then dropped (empty graphs for quiet/agentic users). This mirrors the server's
444
- // engine._user_turn_count EXACTLY so the client's count == what the server ingests. One definition,
445
- // two implementations kept in lockstep — see docs/SESSION-COUNTING.md.
446
- const _HUMAN_PROMPT_SOURCES = new Set(["typed", "queued", "suggestion_accepted"]);
447
- function isGenuineUserTurn(e) {
448
- if (!(e?.type === "user" || e?.role === "user")) return false;
449
- const ps = e?.promptSource;
450
- if (ps != null) return _HUMAN_PROMPT_SOURCES.has(ps); // newer CLI: trust the source label
451
- // older/current builds omit promptSource — a row is a tool RESULT (not a query) when its content
452
- // is a block list carrying a tool_result. Everything else that's role:user is a genuine query.
453
- const c = e?.message?.content;
454
- if (Array.isArray(c) && c.some((b) => b && typeof b === "object" && b.type === "tool_result")) {
455
- return false;
456
- }
457
- return true;
458
- }
459
-
441
+ // USER QUERIES ONLY (not the tool_result rows Claude Code logs as role:"user"). The single
442
+ // definition of a genuine turn now lives in ./session-turns.mjs so scanHistory here, bootstrap's
443
+ // first-run scan, and the server's engine._user_turn_count can never drift see
444
+ // docs/SESSION-COUNTING.md.
460
445
  export async function userTurnCount(path, cap = 5) {
461
446
  let n = 0;
462
447
  const input = createReadStream(path, { encoding: "utf8" });
@@ -640,6 +625,10 @@ async function buildGraph(sp, files, id) {
640
625
  done = true;
641
626
  } else if (s.state === "error") {
642
627
  throw new Error("build failed");
628
+ } else if (s.state === "empty") {
629
+ // server found zero parseable sessions — an honest terminal state, not a 20-min hang
630
+ total = 0;
631
+ done = true;
643
632
  } else if (s.state === "judging" && s.total) {
644
633
  sp.text(`Judging your sessions…${label} ${s.done}/${s.total}`);
645
634
  }
@@ -1017,10 +1006,18 @@ async function backgroundBuild() {
1017
1006
  if (!id) return; // not signed in — nothing to build under
1018
1007
  const files = await scanHistory();
1019
1008
  if (!files.length) return;
1009
+ const stalledPath = join(homedir(), ".skalpel", "build-stalled.json");
1020
1010
  const sp = { text: () => {}, succeed: () => {} }; // silent — no TTY in the background
1021
1011
  try {
1022
1012
  const built = await buildGraph(sp, files, id);
1023
1013
  if (!built) return;
1014
+ // Build landed — clear any "stalled" marker a prior failed run left, so the next SessionStart
1015
+ // doesn't surface a stale build-stalled note after the graph actually finished.
1016
+ try {
1017
+ writeFileSync(stalledPath, JSON.stringify({ stalled: false }));
1018
+ } catch {
1019
+ /* best-effort */
1020
+ }
1024
1021
  let report = null;
1025
1022
  try {
1026
1023
  report = await fetchProfile(id);
@@ -1039,8 +1036,23 @@ async function backgroundBuild() {
1039
1036
  } catch {
1040
1037
  /* the reveal is a bonus; never block the build on it */
1041
1038
  }
1042
- } catch {
1043
- /* fail-open: a failed background build never surfaces; hooks fail open until it succeeds */
1039
+ } catch (e) {
1040
+ // The "I'll notify you the moment it's ready" promise just broke buildGraph threw (e.g. "build
1041
+ // timed out" / "build failed" / an ingest error). Leave a marker so the next SessionStart can tell
1042
+ // the user to re-run `skalpel`, instead of the promised graph silently never arriving.
1043
+ try {
1044
+ mkdirSync(join(homedir(), ".skalpel"), { recursive: true });
1045
+ writeFileSync(
1046
+ stalledPath,
1047
+ JSON.stringify({
1048
+ stalled: true,
1049
+ ts: Date.now(),
1050
+ error: String(e?.message || e).slice(0, 200),
1051
+ }),
1052
+ );
1053
+ } catch {
1054
+ /* even the marker write failed — nothing more we can do; still fail-open, never throw */
1055
+ }
1044
1056
  }
1045
1057
  }
1046
1058