specpi 0.30.0 → 0.31.0

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.
@@ -7,10 +7,10 @@ import { consentPath, granted, revokeConsent } from "./consent.mjs";
7
7
  import { createBroker } from "./broker.mjs";
8
8
  import { ledgerPath, read as readLedger } from "./ledger.mjs";
9
9
  import { usagePath } from "./usage.mjs";
10
+ import { compact } from "./sanitize.mjs";
10
11
  import * as retention from "./questions/retention.mjs";
11
12
  import * as gap from "./questions/gap.mjs";
12
13
  import * as sources from "./questions/sources.mjs";
13
- import * as progress from "./questions/progress.mjs";
14
14
  import * as untrusted from "./questions/untrusted.mjs";
15
15
  import * as capabilities from "./questions/capabilities.mjs";
16
16
 
@@ -27,37 +27,49 @@ export default function jevAdvisor(pi: ExtensionAPI) {
27
27
  const broker = createBroker({ loadSettings: () => settings });
28
28
  const recent: { tool: string; outcome: string }[] = [];
29
29
  let objective = "";
30
+ let currentRequest = "";
31
+ let taskIdentity = "";
32
+ let taskGeneration = 0;
33
+ let sessionActive = false;
34
+
35
+ const refreshObjective = async (ctx: ExtensionContext) => {
36
+ if (!sessionActive) {
37
+ return false;
38
+ }
30
39
 
31
- // System 5's local state. Every field here is something the session already knows; it exists so
32
- // that "ask local state first" has something to ask. Local state cannot answer whether a session
33
- // is stuck, but it answers cheaply whether that question is worth 300ms and a call.
34
- const HISTORY_WINDOW = 12;
35
- const history = {
36
- turn: 0,
37
- signatures: [] as string[],
38
- tools: [] as string[],
39
- errors: [] as string[],
40
- consecutiveErrors: 0,
41
- turnsSinceChange: 0,
42
- filesChanged: 0,
43
- changedThisTurn: false,
44
- nudged: false,
45
- askedAtTurn: undefined as number | undefined,
46
- };
47
- const resetHistory = () => {
48
- history.turn = 0;
49
- history.signatures.length = 0;
50
- history.tools.length = 0;
51
- history.errors.length = 0;
52
- history.consecutiveErrors = 0;
53
- history.turnsSinceChange = 0;
54
- history.filesChanged = 0;
55
- history.changedThisTurn = false;
56
- history.nudged = false;
57
- history.askedAtTurn = undefined;
40
+ const generation = taskGeneration;
41
+ const replies: Promise<{ objective: string; digest: string } | undefined>[] = [];
42
+ let contract;
43
+ try {
44
+ if (settings.master) {
45
+ pi.events.emit("specpi:task-objective", {
46
+ ctx,
47
+ reply: (value: Promise<{ objective: string; digest: string } | undefined>) => replies.push(value),
48
+ });
49
+ }
50
+
51
+ contract = replies.length === 1 ? await replies[0] : undefined;
52
+ } catch {
53
+ // A missing or unavailable workflow owner must not fail an otherwise valid tool.
54
+ }
55
+
56
+ if (generation !== taskGeneration) {
57
+ return false;
58
+ }
59
+
60
+ const next = compact(contract?.objective || currentRequest, 180);
61
+ const identity = contract?.digest ?? currentRequest;
62
+ if (objective !== next || taskIdentity !== identity) {
63
+ objective = next;
64
+ taskIdentity = identity;
65
+ taskGeneration += 1;
66
+ recent.length = 0;
67
+ }
68
+
69
+ return true;
58
70
  };
59
71
 
60
- const enabled = (system: string) => settings.master && settings.systems[system] === true;
72
+ const enabled = (system: string) => sessionActive && settings.master && settings.systems[system] === true;
61
73
 
62
74
  // System 6: decide once, before the first provider request, whether this session will need a
63
75
  // withdrawn tool group -- and offer it now rather than at turn 6.
@@ -80,14 +92,17 @@ export default function jevAdvisor(pi: ExtensionAPI) {
80
92
  };
81
93
 
82
94
  pi.on("session_start", () => {
95
+ sessionActive = true;
83
96
  settings = loadSettings();
84
97
  if (!settings.startup) {
85
98
  settings = { ...settings, master: false };
86
99
  }
87
100
 
88
101
  broker.reset();
102
+ objective = "";
103
+ currentRequest = "";
104
+ taskGeneration += 1;
89
105
  recent.length = 0;
90
- resetHistory();
91
106
  capabilityAsked = false;
92
107
  capabilityDeclined.clear();
93
108
  });
@@ -96,24 +111,36 @@ export default function jevAdvisor(pi: ExtensionAPI) {
96
111
  // finish, not reset: the counts are published once more as an ended session so anything
97
112
  // reading them from outside -- SpecPi Chat's panel, most of all -- shows what the session
98
113
  // actually spent rather than a zeroed live one.
114
+ sessionActive = false;
99
115
  broker.finish();
116
+ objective = "";
117
+ currentRequest = "";
118
+ taskGeneration += 1;
100
119
  recent.length = 0;
101
- resetHistory();
102
120
  });
103
121
 
104
- pi.on("turn_start", (event: any) => {
105
- history.turn = typeof event?.turnIndex === "number" ? event.turnIndex : history.turn + 1;
106
- history.changedThisTurn = false;
122
+ // The workflow owner reads its active contract, not rendered Markdown. Without one, use only
123
+ // the bounded current request; never recover an objective by searching stored conversations.
124
+ pi.on("before_agent_start", async (event: any, ctx: ExtensionContext) => {
125
+ currentRequest = compact(event?.prompt ?? "", 180);
126
+ taskGeneration += 1;
127
+ pi.events.emit("specpi:task-changing");
128
+ await refreshObjective(ctx);
107
129
  });
108
-
109
- // The task objective is the one piece of context every system wants, and it is already in the
110
- // system prompt, so reading it here costs nothing extra.
111
- pi.on("before_agent_start", (event: any) => {
112
- const match = /\[SPECPI TASK CONTRACT\]\n([^\n]{0,200})/u.exec(event?.systemPrompt ?? "");
113
- if (match) {
114
- objective = match[1];
130
+ pi.on("input", async (event: any, ctx: ExtensionContext) => {
131
+ if (event.streamingBehavior === "steer" && event.source !== "extension") {
132
+ currentRequest = compact(event.text ?? "", 180);
133
+ taskGeneration += 1;
134
+ pi.events.emit("specpi:task-changing");
135
+ await refreshObjective(ctx);
115
136
  }
116
137
  });
138
+ pi.on("session_tree", () => {
139
+ objective = "";
140
+ currentRequest = "";
141
+ taskGeneration += 1;
142
+ recent.length = 0;
143
+ });
117
144
 
118
145
  // Once per session, whatever the answer: asking later would be the mid-session flip the probe
119
146
  // priced at three times the cost of doing it now.
@@ -166,8 +193,12 @@ export default function jevAdvisor(pi: ExtensionAPI) {
166
193
  return;
167
194
  }
168
195
 
169
- const result = await broker.request({
196
+ const generation = taskGeneration;
197
+ await broker.request({
170
198
  system: "capability",
199
+ isCurrent: () => generation === taskGeneration,
200
+ profile: "capability",
201
+ trigger: local.reasons,
171
202
  state: capabilities.buildInput({
172
203
  prompt: event?.prompt,
173
204
  reasons: local.reasons,
@@ -180,44 +211,51 @@ export default function jevAdvisor(pi: ExtensionAPI) {
180
211
  decide: (answers: any) => {
181
212
  const advice = capabilities.decide(answers, available);
182
213
 
183
- return { applied: advice.propose.length > 0, decision: advice };
214
+ return { decision: advice };
184
215
  },
185
- });
186
- if (!result.ok) {
187
- return;
188
- }
189
-
190
- const advice = result.decision;
191
- if (advice.suggestDelegation) {
192
- // A suggestion, never an activation: delegation binds a model and a host and has
193
- // its own command, which is why the capability table deliberately omits it.
194
- ctx.ui.notify(
195
- "Jev: this looks like a question a delegated read-only session could answer over many files. Run /delegate on if you want it.",
196
- "info",
197
- );
198
- }
199
-
200
- for (const id of advice.propose) {
201
- const capability = table.findCapability(id);
202
- if (!capability || capabilityDeclined.has(id)) {
203
- continue;
204
- }
216
+ apply: async (advice: any) => {
217
+ const effects: string[] = [];
218
+ if (advice.suggestDelegation) {
219
+ // A suggestion, never an activation: delegation binds a model and a host and has
220
+ // its own command, which is why the capability table deliberately omits it.
221
+ ctx.ui.notify(
222
+ "Jev: this looks like a question a delegated read-only session could answer over many files. Run /delegate on if you want it.",
223
+ "info",
224
+ );
225
+ effects.push("notification");
226
+ }
205
227
 
206
- const pending = table.missingTools(pi.getActiveTools(), capability);
207
- // The same confirmation `request_capability` shows, pre-filled and moved to turn 0.
208
- // Authority is unchanged: the human still decides, and declining is remembered so
209
- // nothing asks twice in one session.
210
- const accepted = await ctx.ui.confirm(
211
- `Allow ${capability.label} for this session?`,
212
- `Jev expects this request to ${capability.summary}, from the request itself rather than from anything it has done yet.\n\nThis offers ${pending.length} tool${pending.length === 1 ? "" : "s"} for the rest of this session and adds ${capability.schemaCost}. Accepting now is materially cheaper than accepting later: activating it mid-session also discards the cached prompt prefix, which measured about 20% of a mid-length attempt's cost. Withdraw it with ${capability.command} off.`,
213
- );
214
- if (!accepted) {
215
- capabilityDeclined.add(id);
216
- continue;
217
- }
228
+ for (const id of advice.propose) {
229
+ const capability = table.findCapability(id);
230
+ if (!capability || capabilityDeclined.has(id)) {
231
+ continue;
232
+ }
233
+
234
+ const pending = table.missingTools(pi.getActiveTools(), capability);
235
+ // The same confirmation `request_capability` shows, pre-filled and moved to turn 0.
236
+ // Authority is unchanged: the human still decides, and declining is remembered so
237
+ // nothing asks twice in one session.
238
+ const accepted = await ctx.ui.confirm(
239
+ `Allow ${capability.label} for this session?`,
240
+ `Jev expects this request to ${capability.summary}, from the request itself rather than from anything it has done yet.\n\nThis offers ${pending.length} tool${pending.length === 1 ? "" : "s"} for the rest of this session and adds ${capability.schemaCost}. Accepting now is materially cheaper than accepting later: activating it mid-session also discards the cached prompt prefix, which measured about 20% of a mid-length attempt's cost. Withdraw it with ${capability.command} off.`,
241
+ );
242
+ effects.push("capability-proposal");
243
+ if (generation !== taskGeneration || !enabled("capability") || ctx.signal?.aborted) {
244
+ break;
245
+ }
246
+
247
+ if (!accepted) {
248
+ capabilityDeclined.add(id);
249
+ continue;
250
+ }
251
+
252
+ syncActiveTools(pi, capability.tools, true);
253
+ effects.push("capability-activated");
254
+ }
218
255
 
219
- syncActiveTools(pi, capability.tools, true);
220
- }
256
+ return { applied: effects.length > 0, effects };
257
+ },
258
+ });
221
259
  } catch {
222
260
  // Nothing here may prevent a session from starting.
223
261
  }
@@ -226,27 +264,13 @@ export default function jevAdvisor(pi: ExtensionAPI) {
226
264
  // System 1: condense a spent tool result before it is appended. Doing this after the fact would
227
265
  // rewrite a cached prefix; on arrival it never touches one.
228
266
  pi.on("tool_result", async (event: any, ctx: ExtensionContext) => {
229
- // Bookkeeping first, and unconditionally. Retention's own eligibility gate returns early on
230
- // most results, and a history that only recorded the large read-only ones would be blind to
231
- // exactly the short repeated failures system 5 exists to notice.
232
- if (event?.isError === true) {
233
- history.consecutiveErrors += 1;
234
- history.errors.push(retention.resultText(event).slice(0, 200));
235
- if (history.errors.length > HISTORY_WINDOW) {
236
- history.errors.shift();
237
- }
238
- } else {
239
- history.consecutiveErrors = 0;
240
- if (progress.MUTATING_TOOLS.has(event?.toolName)) {
241
- history.filesChanged += 1;
242
- history.changedThisTurn = true;
243
- }
267
+ if (!(await refreshObjective(ctx))) {
268
+ return;
244
269
  }
245
270
 
246
- // Every result, not only the ones retention asked about. It was written in one place --
247
- // inside retention's success path -- so a session with retention off, or with retention's
248
- // budget spent, handed every other system an empty history for its whole length while
249
- // their question sets said history was what they weighed.
271
+ const generation = taskGeneration;
272
+ // Every result, not only the ones retention asked about, so retention's own view of recent
273
+ // work does not depend on which results it happened to be interested in.
250
274
  recent.push({ tool: String(event?.toolName ?? ""), outcome: event?.isError === true ? "error" : "ok" });
251
275
  if (recent.length > MAX_RECENT) {
252
276
  recent.shift();
@@ -271,6 +295,8 @@ export default function jevAdvisor(pi: ExtensionAPI) {
271
295
  // interested. System 7's own budget therefore only binds when retention is off or
272
296
  // the result was too small for it.
273
297
  system: wantRetention ? "retention" : "untrusted",
298
+ profile: wantRetention ? "retention" : "untrusted",
299
+ isCurrent: () => generation === taskGeneration,
274
300
  state: retention.buildInput({ event, objective, recent }),
275
301
  questions: {
276
302
  ...(wantRetention ? retention.questions() : {}),
@@ -283,17 +309,22 @@ export default function jevAdvisor(pi: ExtensionAPI) {
283
309
  // because its length is the saving: computing it a second time to measure it would
284
310
  // be the measurement inventing its own number.
285
311
  decide: (answers: any) => {
286
- const verdict = wantRetention
287
- ? retention.decide(answers)
288
- : { elide: false, reason: "retention-off" };
312
+ let verdict = wantRetention ? retention.decide(answers) : { elide: false, reason: "retention-off" };
289
313
  const flagged = wantUntrusted && untrusted.decide(answers).banner;
290
314
  // Order matters: shorten first, then mark. A banner belongs at the top of
291
315
  // whatever the model is actually going to read.
292
316
  const body = verdict.elide ? retention.digest(text, { tool: event.toolName, bytes }) : text;
293
- const replacement = flagged ? untrusted.mark(body) : body;
317
+ let replacement = flagged ? untrusted.mark(body) : body;
318
+ if (verdict.elide && Buffer.byteLength(replacement, "utf8") >= bytes) {
319
+ verdict = { elide: false, reason: "no-byte-saving" };
320
+ replacement = flagged ? untrusted.mark(text) : text;
321
+ }
322
+
323
+ const marked = flagged && replacement !== body;
294
324
 
295
325
  return {
296
- applied: verdict.elide || flagged,
326
+ applied: verdict.elide || marked,
327
+ effects: [...(verdict.elide ? ["elision"] : []), ...(marked ? ["warning"] : [])],
297
328
  savedBytes: verdict.elide ? bytes - Buffer.byteLength(replacement, "utf8") : 0,
298
329
  // retention.decide already names why it declined; carrying that into the
299
330
  // ledger is what makes "asked and did nothing" diagnosable later.
@@ -334,213 +365,134 @@ export default function jevAdvisor(pi: ExtensionAPI) {
334
365
  });
335
366
 
336
367
  pi.on("tool_call", async (event: any, ctx: ExtensionContext) => {
337
- history.signatures.push(progress.signature(event.toolName, event.input));
338
- history.tools.push(event.toolName);
339
- if (history.signatures.length > HISTORY_WINDOW) {
340
- history.signatures.shift();
341
- }
342
-
343
- if (history.tools.length > HISTORY_WINDOW) {
344
- history.tools.shift();
345
- }
346
-
347
- // System 2: triage a capability gap before tool-wishlist writes it. `event.input` is
348
- // documented as mutable, so this patches the report in place rather than duplicating any
349
- // of the wishlist's authority logic. Nothing here records a decision.
350
- if (event.toolName === "report_capability_gap" && enabled("gap")) {
351
- try {
352
- const result = await broker.request({
353
- system: "gap",
354
- state: gap.buildInput({ gap: event.input, existing: [] }),
355
- questions: gap.questions({ gap: event.input, existing: [] }),
356
- ctx,
357
- root: ctx.cwd,
358
- decide: (answers: any) => {
359
- const built = gap.decide(answers);
360
- // Exactly the conditions the caller applies below, so the ledger line says
361
- // what happened rather than what was available. A gated answer that
362
- // duplicates a field the model already filled in changed nothing.
363
- const changes =
364
- (built.blockForSanitization ? 1 : 0) +
365
- (built.canonicalKey && typeof event.input?.canonicalKey !== "string" ? 1 : 0) +
366
- (built.suggestedFix && !event.input?.suggestedFix ? 1 : 0) +
367
- (built.independentImpact ? 1 : 0);
368
-
369
- return { applied: changes > 0, decision: built };
370
- },
371
- });
372
- if (!result.ok) {
373
- return;
374
- }
375
-
376
- const advice = result.decision;
377
- if (advice.blockForSanitization) {
378
- return {
379
- block: true,
380
- reason: "This report appears to contain a credential, an absolute path or other machine-specific detail. Rewrite it with the specifics removed and report it again.",
381
- };
382
- }
383
-
384
- if (advice.canonicalKey && typeof event.input?.canonicalKey !== "string") {
385
- event.input.canonicalKey = advice.canonicalKey;
386
- }
387
-
388
- if (advice.suggestedFix && !event.input?.suggestedFix) {
389
- event.input.suggestedFix = advice.suggestedFix;
390
- }
391
-
392
- // Recorded alongside the model's own claim, never over it: a human reading the
393
- // wishlist should still see what was originally reported.
394
- if (advice.independentImpact) {
395
- event.input.independentImpact = advice.independentImpact;
396
- }
397
- } catch {
398
- return;
399
- }
400
-
401
- return;
402
- }
368
+ const generation = taskGeneration;
403
369
 
404
370
  // System 4: order the sources a delegation batch will snapshot. Ordering only — the same
405
371
  // set is frozen either way, but a child pages through `list_sources` in this order.
406
- if (event.toolName === "delegate" && enabled("sources") && Array.isArray(event.input?.sources)) {
407
- try {
408
- const candidates = event.input.sources
409
- .filter((item: unknown) => typeof item === "string")
410
- .map((item: string) => ({ path: item }));
411
- if (candidates.length < 2) {
412
- return;
413
- }
414
-
415
- const result = await broker.request({
416
- system: "sources",
417
- state: sources.buildInput({ question: event.input?.question ?? objective, candidates }),
418
- questions: sources.questions({ candidates }),
419
- ctx,
420
- root: ctx.cwd,
421
- decide: (answers: any) => {
422
- const built = sources.decide(answers, candidates);
423
- const order = built.ordered.map((item: any) => item.path);
424
- // An ungated run returns the caller's own order, which is not a change and
425
- // must not be recorded as one.
426
- const moved = order.some((item: string, index: number) => item !== candidates[index]?.path);
427
-
428
- return { applied: moved, decision: { ranked: built, ordered: order } };
429
- },
430
- });
431
- if (!result.ok) {
432
- return;
433
- }
372
+ if (
373
+ event.toolName === "delegate" &&
374
+ enabled("sources") &&
375
+ event.input?.operation === "run" &&
376
+ Array.isArray(event.input?.packet?.jobs)
377
+ ) {
378
+ for (const job of event.input.packet.jobs) {
379
+ try {
380
+ if (
381
+ !Array.isArray(job.sources) ||
382
+ job.sources.length < 2 ||
383
+ !job.sources.every((item: unknown) => typeof item === "string")
384
+ ) {
385
+ continue;
386
+ }
434
387
 
435
- const { ranked, ordered } = result.decision;
436
- const missing = event.input.sources.filter((item: string) => !ordered.includes(item));
437
- event.input.sources = [...ordered, ...missing];
438
-
439
- // Two answers the same batch already computed and nothing read. Output is free, so
440
- // they were paid for whether or not anyone looked. A confident "this is not a
441
- // self-contained evidence question" is worth surfacing before specpi-delegation
442
- // freezes up to 200 files and 8 MiB for a child that then cannot answer it.
443
- //
444
- // Advisory only, and deliberately so: the batch still runs, the ceilings are
445
- // unchanged, and with no UI this says nothing rather than blocking.
446
- if (ranked.notWorthDelegating && ctx.hasUI) {
447
- ctx.ui.notify(
448
- `Jev rates this a poor fit for delegation${ranked.jobMode ? ` (it reads as ${ranked.jobMode} work)` : ""}. Running anyway; ${ordered.length + missing.length} sources will be frozen for the child.`,
449
- "warning",
450
- );
388
+ const original = [...job.sources];
389
+ const candidates = original.map((item: string) => ({ path: item }));
390
+ await broker.request({
391
+ system: "sources",
392
+ profile: "sources",
393
+ isCurrent: () => generation === taskGeneration && sessionActive,
394
+ state: sources.buildInput({ question: job.question, mode: job.mode, candidates }),
395
+ questions: sources.questions({ candidates }),
396
+ ctx,
397
+ root: ctx.cwd,
398
+ decide: (answers: any) => ({ decision: sources.decide(answers, candidates) }),
399
+ apply: (ranked: any, { recordEffect }: any) => {
400
+ const ordered = ranked.ordered.map((item: any) => item.path);
401
+ const moved = ordered.some((item: string, index: number) => item !== original[index]);
402
+ // No deduplication: even multiplicity and ungated positions are preserved.
403
+ job.sources = ordered;
404
+ const effects = moved ? ["sources-reordered"] : [];
405
+ if (moved) {
406
+ recordEffect("sources-reordered");
407
+ }
408
+
409
+ if (ranked.notWorthDelegating && ctx.hasUI) {
410
+ ctx.ui.notify(
411
+ `Jev rates this ${job.mode} job a poor fit for delegation. Running anyway; ${ordered.length} sources remain selected.`,
412
+ "warning",
413
+ );
414
+ effects.push("warning");
415
+ }
416
+
417
+ return { applied: effects.length > 0, effects };
418
+ },
419
+ });
420
+ } catch {
421
+ // One unavailable job must not prevent the next job or the delegation call.
451
422
  }
452
- } catch {
453
- return;
454
423
  }
455
424
  }
456
425
  });
457
426
 
458
- // System 5: notice a session that has stopped making progress, while it can still be helped.
459
- //
460
- // THE ONE HANDLER THAT IS NOT AWAITED. Everything else in this file mutates what it inspects --
461
- // a tool result, a tool's input -- so the session has to wait for the
462
- // answer. This one acts on the next turn, and at roughly 300ms a call, awaiting it on a
463
- // thrashing session would add seconds to an attempt to deliver advice that could not have
464
- // changed the turn it was asked during.
465
- pi.on("turn_end", (event: any, ctx: ExtensionContext) => {
466
- // Kept whether or not the system is on, so switching it on mid-session does not start from
467
- // a blank history and immediately look healthy.
468
- history.turnsSinceChange = history.changedThisTurn ? 0 : history.turnsSinceChange + 1;
469
- if (!enabled("progress") || history.nudged) {
470
- return;
471
- }
472
-
473
- const local = progress.suspicious(history);
474
- if (!local.ask) {
427
+ // Wishlist owns collection and persistence. This handshake runs only after its local consent
428
+ // gate; advisory fields never enter model-authored tool arguments or its authority decisions.
429
+ pi.events.on("specpi:gap-triage", (request: any) => {
430
+ if (!enabled("gap")) {
475
431
  return;
476
432
  }
477
433
 
478
- // Recorded before the call rather than after, so a slow answer cannot let the next turn ask
479
- // again while this one is still in flight.
480
- history.askedAtTurn = history.turn;
481
- void (async () => {
482
- try {
434
+ request.reply(
435
+ (async () => {
436
+ const existing = gap.shortlist(request.existing(), request.gap);
437
+ let stored: any;
438
+ let blocked = false;
439
+ let storageError: unknown;
483
440
  const result = await broker.request({
484
- system: "progress",
485
- state: progress.buildInput({ history, objective, reasons: local.reasons }),
486
- questions: progress.questions(),
487
- ctx,
488
- root: ctx.cwd,
489
- decide: (answers: any) => {
490
- const advice = progress.decide(answers);
441
+ system: "gap",
442
+ profile: "gap",
443
+ state: gap.buildInput({ gap: request.gap, existing }),
444
+ questions: gap.questions({ existing }),
445
+ ctx: request.ctx,
446
+ root: request.ctx.cwd,
447
+ signal: request.signal,
448
+ isCurrent: request.isCurrent,
449
+ decide: (answers: any) => ({ decision: gap.decide(answers, existing) }),
450
+ apply: async (advice: any, { isCurrent, recordEffect }: any) => {
451
+ if (advice.blockForSanitization) {
452
+ blocked = true;
453
+
454
+ return { applied: true, effects: ["report-blocked"] };
455
+ }
456
+
457
+ try {
458
+ stored = await request.record(advice, {
459
+ isCurrent,
460
+ onRecorded: ({ assessmentRecorded }: any) => {
461
+ if (assessmentRecorded) {
462
+ recordEffect("assessment-recorded");
463
+ }
464
+ },
465
+ });
466
+ } catch (error) {
467
+ storageError = error;
468
+ throw error;
469
+ }
491
470
 
492
471
  return {
493
- applied: Boolean(advice.nudge),
494
- reason: advice.nudge ? advice.mode : advice.stuck ? "stuck-but-mode-ungated" : "not-stuck",
495
- decision: advice,
472
+ applied: stored.assessmentRecorded,
473
+ effects: stored.assessmentRecorded ? ["assessment-recorded"] : [],
496
474
  };
497
475
  },
498
476
  });
499
- if (!result.ok || !result.decision?.nudge) {
500
- return;
477
+ if (storageError) {
478
+ throw storageError;
501
479
  }
502
480
 
503
- // Write-once, per the standing rule. A second nudge would either repeat a line the
504
- // model already has or contradict it, and neither can be withdrawn: it was appended
505
- // to a prefix that is cached behind it by the time anyone regrets it.
506
- history.nudged = true;
507
- if (ctx.hasUI) {
508
- ctx.ui.notify(
509
- result.decision.needsHuman
510
- ? `Jev progress check: this session looks blocked on something only you can answer. ${result.decision.nudge}`
511
- : `Jev progress check: ${result.decision.nudge}`,
512
- "warning",
513
- );
481
+ if (blocked) {
482
+ return { blocked: true };
514
483
  }
515
484
 
516
- // The plan specified `deliverAs: "nextTurn"`, which is documented as "queued for
517
- // next user prompt, does not interrupt or trigger anything". An unattended session
518
- // has exactly one user prompt, so a nextTurn message would never be delivered -- in
519
- // precisely the case the argument for this system rests on, a headless attempt
520
- // burning its wall clock. "steer" is delivered after the current tool calls finish
521
- // and before the next model request, which is the same append at the same boundary
522
- // and is actually read. triggerTurn is left off so this can never add a turn.
523
- // Suppressed when the session is blocked on something only a person can answer:
524
- // steering a model past a missing credential costs a turn to say nothing.
525
- if (
526
- settings.progressNudge === "message" &&
527
- !result.decision.needsHuman &&
528
- typeof pi.sendMessage === "function"
529
- ) {
530
- pi.sendMessage(
531
- {
532
- customType: "specpi-jev-progress",
533
- content: result.decision.nudge,
534
- display: true,
535
- details: { mode: result.decision.mode, reasons: local.reasons },
536
- },
537
- { deliverAs: "steer" },
538
- );
485
+ if (stored) {
486
+ return { stored };
539
487
  }
540
- } catch {
541
- // The turn has already ended. An advisor must not be able to fail it retroactively.
542
- }
543
- })();
488
+
489
+ if (["session-changed", "context-changed"].includes(result.reason)) {
490
+ return { cancelled: true };
491
+ }
492
+
493
+ return undefined;
494
+ })(),
495
+ );
544
496
  });
545
497
 
546
498
  pi.registerCommand("jev", {
@@ -696,9 +648,10 @@ export default function jevAdvisor(pi: ExtensionAPI) {
696
648
  return;
697
649
  }
698
650
 
699
- const lines = entries.map(
700
- (entry: any) =>
701
- `${entry.at} ${entry.system} ${entry.stateBytes}B ${entry.ok ? `${entry.latencyMs}ms` : entry.reason} ${String(entry.payloadSha256 ?? "").slice(0, 12)} [${(entry.questionKeys ?? []).join(", ")}]`,
651
+ const lines = entries.map((entry: any) =>
652
+ entry.sent === false
653
+ ? `${entry.at} ${entry.system} local abstention: ${entry.reason}`
654
+ : `${entry.at} ${entry.system} ${entry.stateBytes}B ${entry.ok ? `${entry.latencyMs}ms` : entry.reason} ${String(entry.payloadSha256 ?? "").slice(0, 12)} [${(entry.questionKeys ?? []).join(", ")}]`,
702
655
  );
703
656
  ctx.ui.notify(`${lines.join("\n")}\n\nLedger: ${ledgerPath()}`, "info");
704
657