skalpel 4.0.20 → 4.0.22
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 +21 -5
- package/bootstrap.mjs +7 -2
- package/install.mjs +1 -0
- package/package.json +1 -1
- package/reveal.mjs +4 -1
- package/session-turns.mjs +24 -0
- package/skalpel-hook-session.mjs +34 -3
- package/skalpel-hook.mjs +181 -113
- package/skalpel-setup.mjs +34 -22
- package/verify-shadow.mjs +12 -1
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
|
-
|
|
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
|
|
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
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
|
-
|
|
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
|
+
}
|
package/skalpel-hook-session.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
655
|
-
// confirmation), the reset interrupt (most acute), and the server steer.
|
|
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]
|
|
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
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
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
|
-
|
|
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
|
|
package/verify-shadow.mjs
CHANGED
|
@@ -139,7 +139,14 @@ function pmPositionals(args) {
|
|
|
139
139
|
return pos;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// SHARP-5: `--node-options` (npm/pnpm/yarn config flag) is passed through to the lifecycle script's
|
|
143
|
+
// node as NODE_OPTIONS, so `--node-options=--require <any>.js` loads attacker JS before the test runs —
|
|
144
|
+
// arbitrary code execution, exactly the SHARP-2 class but via the PM itself. shell:false does NOT help
|
|
145
|
+
// (the PM applies the flag). Refuse the whole invocation if it appears, in any `=`/space form.
|
|
146
|
+
const NODE_OPTIONS_FLAG = /^--node-options(=|$)/;
|
|
147
|
+
|
|
142
148
|
function validatePM(base, args) {
|
|
149
|
+
if (args.some((x) => NODE_OPTIONS_FLAG.test(x))) return false; // SHARP-5
|
|
143
150
|
const pos = pmPositionals(args);
|
|
144
151
|
if (!pos.length) return false;
|
|
145
152
|
const p0 = pos[0];
|
|
@@ -161,6 +168,7 @@ function validateNPX(args) {
|
|
|
161
168
|
// plain `npx <allowed-tool> [value-less flags]` is a proof.
|
|
162
169
|
for (const a of args) {
|
|
163
170
|
if (/^(-p|--package|-c|--call)(=|$)/.test(a)) return false;
|
|
171
|
+
if (NODE_OPTIONS_FLAG.test(a)) return false; // SHARP-5: npx also forwards --node-options → NODE_OPTIONS
|
|
164
172
|
}
|
|
165
173
|
for (let i = 0; i < args.length; i++) {
|
|
166
174
|
const a = args[i];
|
|
@@ -482,7 +490,10 @@ export function rerunProof({ cmd, args, cwd }) {
|
|
|
482
490
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
483
491
|
windowsHide: true,
|
|
484
492
|
shell: false, // RED LINE: never a shell
|
|
485
|
-
|
|
493
|
+
// SHARP-5 (defense in depth): neutralize NODE_OPTIONS so neither a `--node-options` flag the
|
|
494
|
+
// allowlist somehow let through, nor an inherited NODE_OPTIONS in the parent env, can inject a
|
|
495
|
+
// `--require <attacker>.js` into the proof tool's node process.
|
|
496
|
+
env: { ...process.env, NODE_OPTIONS: "" },
|
|
486
497
|
},
|
|
487
498
|
(err, stdout, stderr) => {
|
|
488
499
|
const tail = (String(stdout || "") + String(stderr || "")).replace(/\s+$/, "");
|