specpi 0.30.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ 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";
@@ -27,6 +28,48 @@ export default function jevAdvisor(pi: ExtensionAPI) {
27
28
  const broker = createBroker({ loadSettings: () => settings });
28
29
  const recent: { tool: string; outcome: string }[] = [];
29
30
  let objective = "";
31
+ let currentRequest = "";
32
+ let taskIdentity = "";
33
+ let taskGeneration = 0;
34
+ let sessionActive = false;
35
+
36
+ const refreshObjective = async (ctx: ExtensionContext) => {
37
+ if (!sessionActive) {
38
+ return false;
39
+ }
40
+
41
+ const generation = taskGeneration;
42
+ const replies: Promise<{ objective: string; digest: string } | undefined>[] = [];
43
+ let contract;
44
+ try {
45
+ if (settings.master) {
46
+ pi.events.emit("specpi:task-objective", {
47
+ ctx,
48
+ reply: (value: Promise<{ objective: string; digest: string } | undefined>) => replies.push(value),
49
+ });
50
+ }
51
+
52
+ contract = replies.length === 1 ? await replies[0] : undefined;
53
+ } catch {
54
+ // A missing or unavailable workflow owner must not fail an otherwise valid tool.
55
+ }
56
+
57
+ if (generation !== taskGeneration) {
58
+ return false;
59
+ }
60
+
61
+ const next = compact(contract?.objective || currentRequest, 180);
62
+ const identity = contract?.digest ?? currentRequest;
63
+ if (objective !== next || taskIdentity !== identity) {
64
+ objective = next;
65
+ taskIdentity = identity;
66
+ taskGeneration += 1;
67
+ recent.length = 0;
68
+ resetTaskHistory();
69
+ }
70
+
71
+ return true;
72
+ };
30
73
 
31
74
  // System 5's local state. Every field here is something the session already knows; it exists so
32
75
  // that "ask local state first" has something to ask. Local state cannot answer whether a session
@@ -57,7 +100,14 @@ export default function jevAdvisor(pi: ExtensionAPI) {
57
100
  history.askedAtTurn = undefined;
58
101
  };
59
102
 
60
- const enabled = (system: string) => settings.master && settings.systems[system] === true;
103
+ const resetTaskHistory = () => {
104
+ const nudged = history.nudged;
105
+ resetHistory();
106
+ // Changing task context must not reset the existing once-per-session steering bound.
107
+ history.nudged = nudged;
108
+ };
109
+
110
+ const enabled = (system: string) => sessionActive && settings.master && settings.systems[system] === true;
61
111
 
62
112
  // System 6: decide once, before the first provider request, whether this session will need a
63
113
  // withdrawn tool group -- and offer it now rather than at turn 6.
@@ -80,12 +130,16 @@ export default function jevAdvisor(pi: ExtensionAPI) {
80
130
  };
81
131
 
82
132
  pi.on("session_start", () => {
133
+ sessionActive = true;
83
134
  settings = loadSettings();
84
135
  if (!settings.startup) {
85
136
  settings = { ...settings, master: false };
86
137
  }
87
138
 
88
139
  broker.reset();
140
+ objective = "";
141
+ currentRequest = "";
142
+ taskGeneration += 1;
89
143
  recent.length = 0;
90
144
  resetHistory();
91
145
  capabilityAsked = false;
@@ -96,7 +150,11 @@ export default function jevAdvisor(pi: ExtensionAPI) {
96
150
  // finish, not reset: the counts are published once more as an ended session so anything
97
151
  // reading them from outside -- SpecPi Chat's panel, most of all -- shows what the session
98
152
  // actually spent rather than a zeroed live one.
153
+ sessionActive = false;
99
154
  broker.finish();
155
+ objective = "";
156
+ currentRequest = "";
157
+ taskGeneration += 1;
100
158
  recent.length = 0;
101
159
  resetHistory();
102
160
  });
@@ -106,14 +164,29 @@ export default function jevAdvisor(pi: ExtensionAPI) {
106
164
  history.changedThisTurn = false;
107
165
  });
108
166
 
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];
167
+ // The workflow owner reads its active contract, not rendered Markdown. Without one, use only
168
+ // the bounded current request; never recover an objective by searching stored conversations.
169
+ pi.on("before_agent_start", async (event: any, ctx: ExtensionContext) => {
170
+ currentRequest = compact(event?.prompt ?? "", 180);
171
+ taskGeneration += 1;
172
+ pi.events.emit("specpi:task-changing");
173
+ await refreshObjective(ctx);
174
+ });
175
+ pi.on("input", async (event: any, ctx: ExtensionContext) => {
176
+ if (event.streamingBehavior === "steer" && event.source !== "extension") {
177
+ currentRequest = compact(event.text ?? "", 180);
178
+ taskGeneration += 1;
179
+ pi.events.emit("specpi:task-changing");
180
+ await refreshObjective(ctx);
115
181
  }
116
182
  });
183
+ pi.on("session_tree", () => {
184
+ objective = "";
185
+ currentRequest = "";
186
+ taskGeneration += 1;
187
+ recent.length = 0;
188
+ resetTaskHistory();
189
+ });
117
190
 
118
191
  // Once per session, whatever the answer: asking later would be the mid-session flip the probe
119
192
  // priced at three times the cost of doing it now.
@@ -166,8 +239,11 @@ export default function jevAdvisor(pi: ExtensionAPI) {
166
239
  return;
167
240
  }
168
241
 
169
- const result = await broker.request({
242
+ const generation = taskGeneration;
243
+ await broker.request({
170
244
  system: "capability",
245
+ isCurrent: () => generation === taskGeneration,
246
+ profile: "capability",
171
247
  state: capabilities.buildInput({
172
248
  prompt: event?.prompt,
173
249
  reasons: local.reasons,
@@ -180,44 +256,51 @@ export default function jevAdvisor(pi: ExtensionAPI) {
180
256
  decide: (answers: any) => {
181
257
  const advice = capabilities.decide(answers, available);
182
258
 
183
- return { applied: advice.propose.length > 0, decision: advice };
259
+ return { decision: advice };
184
260
  },
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
- }
261
+ apply: async (advice: any) => {
262
+ const effects: string[] = [];
263
+ if (advice.suggestDelegation) {
264
+ // A suggestion, never an activation: delegation binds a model and a host and has
265
+ // its own command, which is why the capability table deliberately omits it.
266
+ ctx.ui.notify(
267
+ "Jev: this looks like a question a delegated read-only session could answer over many files. Run /delegate on if you want it.",
268
+ "info",
269
+ );
270
+ effects.push("notification");
271
+ }
205
272
 
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
- }
273
+ for (const id of advice.propose) {
274
+ const capability = table.findCapability(id);
275
+ if (!capability || capabilityDeclined.has(id)) {
276
+ continue;
277
+ }
278
+
279
+ const pending = table.missingTools(pi.getActiveTools(), capability);
280
+ // The same confirmation `request_capability` shows, pre-filled and moved to turn 0.
281
+ // Authority is unchanged: the human still decides, and declining is remembered so
282
+ // nothing asks twice in one session.
283
+ const accepted = await ctx.ui.confirm(
284
+ `Allow ${capability.label} for this session?`,
285
+ `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.`,
286
+ );
287
+ effects.push("capability-proposal");
288
+ if (generation !== taskGeneration || !enabled("capability") || ctx.signal?.aborted) {
289
+ break;
290
+ }
291
+
292
+ if (!accepted) {
293
+ capabilityDeclined.add(id);
294
+ continue;
295
+ }
296
+
297
+ syncActiveTools(pi, capability.tools, true);
298
+ effects.push("capability-activated");
299
+ }
218
300
 
219
- syncActiveTools(pi, capability.tools, true);
220
- }
301
+ return { applied: effects.length > 0, effects };
302
+ },
303
+ });
221
304
  } catch {
222
305
  // Nothing here may prevent a session from starting.
223
306
  }
@@ -226,6 +309,11 @@ export default function jevAdvisor(pi: ExtensionAPI) {
226
309
  // System 1: condense a spent tool result before it is appended. Doing this after the fact would
227
310
  // rewrite a cached prefix; on arrival it never touches one.
228
311
  pi.on("tool_result", async (event: any, ctx: ExtensionContext) => {
312
+ if (!(await refreshObjective(ctx))) {
313
+ return;
314
+ }
315
+
316
+ const generation = taskGeneration;
229
317
  // Bookkeeping first, and unconditionally. Retention's own eligibility gate returns early on
230
318
  // most results, and a history that only recorded the large read-only ones would be blind to
231
319
  // exactly the short repeated failures system 5 exists to notice.
@@ -271,6 +359,8 @@ export default function jevAdvisor(pi: ExtensionAPI) {
271
359
  // interested. System 7's own budget therefore only binds when retention is off or
272
360
  // the result was too small for it.
273
361
  system: wantRetention ? "retention" : "untrusted",
362
+ profile: wantRetention ? "retention" : "untrusted",
363
+ isCurrent: () => generation === taskGeneration,
274
364
  state: retention.buildInput({ event, objective, recent }),
275
365
  questions: {
276
366
  ...(wantRetention ? retention.questions() : {}),
@@ -283,17 +373,22 @@ export default function jevAdvisor(pi: ExtensionAPI) {
283
373
  // because its length is the saving: computing it a second time to measure it would
284
374
  // be the measurement inventing its own number.
285
375
  decide: (answers: any) => {
286
- const verdict = wantRetention
287
- ? retention.decide(answers)
288
- : { elide: false, reason: "retention-off" };
376
+ let verdict = wantRetention ? retention.decide(answers) : { elide: false, reason: "retention-off" };
289
377
  const flagged = wantUntrusted && untrusted.decide(answers).banner;
290
378
  // Order matters: shorten first, then mark. A banner belongs at the top of
291
379
  // whatever the model is actually going to read.
292
380
  const body = verdict.elide ? retention.digest(text, { tool: event.toolName, bytes }) : text;
293
- const replacement = flagged ? untrusted.mark(body) : body;
381
+ let replacement = flagged ? untrusted.mark(body) : body;
382
+ if (verdict.elide && Buffer.byteLength(replacement, "utf8") >= bytes) {
383
+ verdict = { elide: false, reason: "no-byte-saving" };
384
+ replacement = flagged ? untrusted.mark(text) : text;
385
+ }
386
+
387
+ const marked = flagged && replacement !== body;
294
388
 
295
389
  return {
296
- applied: verdict.elide || flagged,
390
+ applied: verdict.elide || marked,
391
+ effects: [...(verdict.elide ? ["elision"] : []), ...(marked ? ["warning"] : [])],
297
392
  savedBytes: verdict.elide ? bytes - Buffer.byteLength(replacement, "utf8") : 0,
298
393
  // retention.decide already names why it declined; carrying that into the
299
394
  // ledger is what makes "asked and did nothing" diagnosable later.
@@ -334,6 +429,7 @@ export default function jevAdvisor(pi: ExtensionAPI) {
334
429
  });
335
430
 
336
431
  pi.on("tool_call", async (event: any, ctx: ExtensionContext) => {
432
+ const generation = taskGeneration;
337
433
  history.signatures.push(progress.signature(event.toolName, event.input));
338
434
  history.tools.push(event.toolName);
339
435
  if (history.signatures.length > HISTORY_WINDOW) {
@@ -344,115 +440,132 @@ export default function jevAdvisor(pi: ExtensionAPI) {
344
440
  history.tools.shift();
345
441
  }
346
442
 
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
- }
443
+ // System 4: order the sources a delegation batch will snapshot. Ordering only — the same
444
+ // set is frozen either way, but a child pages through `list_sources` in this order.
445
+ if (
446
+ event.toolName === "delegate" &&
447
+ enabled("sources") &&
448
+ event.input?.operation === "run" &&
449
+ Array.isArray(event.input?.packet?.jobs)
450
+ ) {
451
+ for (const job of event.input.packet.jobs) {
452
+ try {
453
+ if (
454
+ !Array.isArray(job.sources) ||
455
+ job.sources.length < 2 ||
456
+ !job.sources.every((item: unknown) => typeof item === "string")
457
+ ) {
458
+ continue;
459
+ }
391
460
 
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;
461
+ const original = [...job.sources];
462
+ const candidates = original.map((item: string) => ({ path: item }));
463
+ await broker.request({
464
+ system: "sources",
465
+ profile: "sources",
466
+ isCurrent: () => generation === taskGeneration && sessionActive,
467
+ state: sources.buildInput({ question: job.question, mode: job.mode, candidates }),
468
+ questions: sources.questions({ candidates }),
469
+ ctx,
470
+ root: ctx.cwd,
471
+ decide: (answers: any) => ({ decision: sources.decide(answers, candidates) }),
472
+ apply: (ranked: any, { recordEffect }: any) => {
473
+ const ordered = ranked.ordered.map((item: any) => item.path);
474
+ const moved = ordered.some((item: string, index: number) => item !== original[index]);
475
+ // No deduplication: even multiplicity and ungated positions are preserved.
476
+ job.sources = ordered;
477
+ const effects = moved ? ["sources-reordered"] : [];
478
+ if (moved) {
479
+ recordEffect("sources-reordered");
480
+ }
481
+
482
+ if (ranked.notWorthDelegating && ctx.hasUI) {
483
+ ctx.ui.notify(
484
+ `Jev rates this ${job.mode} job a poor fit for delegation. Running anyway; ${ordered.length} sources remain selected.`,
485
+ "warning",
486
+ );
487
+ effects.push("warning");
488
+ }
489
+
490
+ return { applied: effects.length > 0, effects };
491
+ },
492
+ });
493
+ } catch {
494
+ // One unavailable job must not prevent the next job or the delegation call.
396
495
  }
397
- } catch {
398
- return;
399
496
  }
497
+ }
498
+ });
400
499
 
500
+ // Wishlist owns collection and persistence. This handshake runs only after its local consent
501
+ // gate; advisory fields never enter model-authored tool arguments or its authority decisions.
502
+ pi.events.on("specpi:gap-triage", (request: any) => {
503
+ if (!enabled("gap")) {
401
504
  return;
402
505
  }
403
506
 
404
- // System 4: order the sources a delegation batch will snapshot. Ordering only — the same
405
- // 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
-
507
+ request.reply(
508
+ (async () => {
509
+ const existing = gap.shortlist(request.existing(), request.gap);
510
+ let stored: any;
511
+ let blocked = false;
512
+ let storageError: unknown;
415
513
  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);
514
+ system: "gap",
515
+ profile: "gap",
516
+ state: gap.buildInput({ gap: request.gap, existing }),
517
+ questions: gap.questions({ existing }),
518
+ ctx: request.ctx,
519
+ root: request.ctx.cwd,
520
+ signal: request.signal,
521
+ isCurrent: request.isCurrent,
522
+ decide: (answers: any) => ({ decision: gap.decide(answers, existing) }),
523
+ apply: async (advice: any, { isCurrent, recordEffect }: any) => {
524
+ if (advice.blockForSanitization) {
525
+ blocked = true;
526
+
527
+ return { applied: true, effects: ["report-blocked"] };
528
+ }
529
+
530
+ try {
531
+ stored = await request.record(advice, {
532
+ isCurrent,
533
+ onRecorded: ({ assessmentRecorded }: any) => {
534
+ if (assessmentRecorded) {
535
+ recordEffect("assessment-recorded");
536
+ }
537
+ },
538
+ });
539
+ } catch (error) {
540
+ storageError = error;
541
+ throw error;
542
+ }
427
543
 
428
- return { applied: moved, decision: { ranked: built, ordered: order } };
544
+ return {
545
+ applied: stored.assessmentRecorded,
546
+ effects: stored.assessmentRecorded ? ["assessment-recorded"] : [],
547
+ };
429
548
  },
430
549
  });
431
- if (!result.ok) {
432
- return;
550
+ if (storageError) {
551
+ throw storageError;
433
552
  }
434
553
 
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
- );
554
+ if (blocked) {
555
+ return { blocked: true };
451
556
  }
452
- } catch {
453
- return;
454
- }
455
- }
557
+
558
+ if (stored) {
559
+ return { stored };
560
+ }
561
+
562
+ if (["session-changed", "context-changed"].includes(result.reason)) {
563
+ return { cancelled: true };
564
+ }
565
+
566
+ return undefined;
567
+ })(),
568
+ );
456
569
  });
457
570
 
458
571
  // System 5: notice a session that has stopped making progress, while it can still be helped.
@@ -478,10 +591,13 @@ export default function jevAdvisor(pi: ExtensionAPI) {
478
591
  // Recorded before the call rather than after, so a slow answer cannot let the next turn ask
479
592
  // again while this one is still in flight.
480
593
  history.askedAtTurn = history.turn;
594
+ const generation = taskGeneration;
481
595
  void (async () => {
482
596
  try {
483
- const result = await broker.request({
597
+ await broker.request({
484
598
  system: "progress",
599
+ profile: "progress",
600
+ isCurrent: () => generation === taskGeneration,
485
601
  state: progress.buildInput({ history, objective, reasons: local.reasons }),
486
602
  questions: progress.questions(),
487
603
  ctx,
@@ -490,53 +606,54 @@ export default function jevAdvisor(pi: ExtensionAPI) {
490
606
  const advice = progress.decide(answers);
491
607
 
492
608
  return {
493
- applied: Boolean(advice.nudge),
494
609
  reason: advice.nudge ? advice.mode : advice.stuck ? "stuck-but-mode-ungated" : "not-stuck",
495
610
  decision: advice,
496
611
  };
497
612
  },
498
- });
499
- if (!result.ok || !result.decision?.nudge) {
500
- return;
501
- }
502
-
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
- );
514
- }
613
+ apply: (advice: any) => {
614
+ const effects = [];
615
+ if (!advice.nudge || history.nudged) {
616
+ return { applied: false };
617
+ }
618
+
619
+ if (ctx.hasUI) {
620
+ ctx.ui.notify(
621
+ advice.needsHuman
622
+ ? `Jev progress check: this session looks blocked on something only you can answer. ${advice.nudge}`
623
+ : `Jev progress check: ${advice.nudge}`,
624
+ "warning",
625
+ );
626
+ effects.push("notification");
627
+ }
628
+
629
+ // Queue one fixed instruction, never trigger an extra turn. Queueing is
630
+ // observable; eventual consumption by the model is not claimed here.
631
+ if (
632
+ settings.progressNudge === "message" &&
633
+ !advice.needsHuman &&
634
+ typeof pi.sendMessage === "function"
635
+ ) {
636
+ pi.sendMessage(
637
+ {
638
+ customType: "specpi-jev-progress",
639
+ content: advice.nudge,
640
+ display: true,
641
+ details: { mode: advice.mode, reasons: local.reasons },
642
+ },
643
+ { deliverAs: "steer" },
644
+ );
645
+ effects.push("steering-queued");
646
+ }
647
+
648
+ history.nudged = effects.length > 0;
515
649
 
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
- );
539
- }
650
+ return {
651
+ applied: effects.length > 0,
652
+ effects,
653
+ reason: effects.length > 0 ? advice.mode : "no-delivery-channel",
654
+ };
655
+ },
656
+ });
540
657
  } catch {
541
658
  // The turn has already ended. An advisor must not be able to fail it retroactively.
542
659
  }
@@ -696,9 +813,10 @@ export default function jevAdvisor(pi: ExtensionAPI) {
696
813
  return;
697
814
  }
698
815
 
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(", ")}]`,
816
+ const lines = entries.map((entry: any) =>
817
+ entry.sent === false
818
+ ? `${entry.at} ${entry.system} local abstention: ${entry.reason}`
819
+ : `${entry.at} ${entry.system} ${entry.stateBytes}B ${entry.ok ? `${entry.latencyMs}ms` : entry.reason} ${String(entry.payloadSha256 ?? "").slice(0, 12)} [${(entry.questionKeys ?? []).join(", ")}]`,
702
820
  );
703
821
  ctx.ui.notify(`${lines.join("\n")}\n\nLedger: ${ledgerPath()}`, "info");
704
822