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.
@@ -471,6 +471,53 @@ function sanitizeGap(gap) {
471
471
  };
472
472
  }
473
473
 
474
+ // This is a second opinion on the same report, not corroborating evidence. It cannot affect
475
+ // identity, priority, qualification, human selection, or retirement.
476
+ function sanitizeAssessment(value, knownKeys) {
477
+ if (!value || typeof value !== "object") {
478
+ return undefined;
479
+ }
480
+
481
+ const result = { source: "jev", basis: "reported-observation" };
482
+ if (["minor", "degraded", "blocked"].includes(value.impactOpinion)) {
483
+ result.impactOpinion = value.impactOpinion;
484
+ }
485
+
486
+ if (["tool", "skill", "prompt", "config", "bug", "unknown"].includes(value.suggestedFix)) {
487
+ result.suggestedFix = value.suggestedFix;
488
+ }
489
+
490
+ if (
491
+ typeof value.matchedKey === "string" &&
492
+ /^[a-z0-9][a-z0-9-]{0,119}$/u.test(value.matchedKey) &&
493
+ (!knownKeys || knownKeys.has(value.matchedKey))
494
+ ) {
495
+ result.matchedKey = value.matchedKey;
496
+ }
497
+
498
+ if (value.transient === true) {
499
+ result.transient = true;
500
+ }
501
+
502
+ return Object.keys(result).length > 2 ? result : undefined;
503
+ }
504
+
505
+ /** Read only bounded, sanitized local improvement records; never Pi state or private evidence. */
506
+ export function wishlistTriageCandidates(stateDir) {
507
+ if (readCollectionMode(stateDir) !== "on") {
508
+ return [];
509
+ }
510
+
511
+ const files = pathsFor(stateDir);
512
+ const { events } = readEventsFile(files.events);
513
+ const { decisions } = readDecisionsFile(files.decisions);
514
+
515
+ return aggregateEvents(events, { decisions }).map((item) => ({
516
+ canonicalKey: item.canonicalKey,
517
+ title: sanitizeReportText(item.title, 80),
518
+ }));
519
+ }
520
+
474
521
  function sanitizeEvidenceList(value, maxItems, label) {
475
522
  if (!isValidEvidenceList(value, maxItems)) {
476
523
  throw new Error(`${label} must contain 1 to ${maxItems} strings of at most 240 characters`);
@@ -654,6 +701,10 @@ export function aggregateEvents(events, options = {}) {
654
701
  scenarios: recentUnique("scenario"),
655
702
  limitations: recentUnique("limitation"),
656
703
  workarounds: recentUnique("workaround"),
704
+ assessment: [...ordered]
705
+ .reverse()
706
+ .map((event) => sanitizeAssessment(event.assessment))
707
+ .find(Boolean),
657
708
  };
658
709
  })
659
710
  .sort(
@@ -884,6 +935,26 @@ function renderGroup(lines, group) {
884
935
  lines.push(`- Review needed: yes`, `- Unresolved post-retirement signals: ${group.reviewSignalCount}`);
885
936
  }
886
937
 
938
+ if (group.assessment) {
939
+ const opinion = group.assessment;
940
+ lines.push("", "**Jev advisory opinion** (based on the reported account, not independent evidence)");
941
+ if (opinion.impactOpinion) {
942
+ lines.push(`- Impact opinion: ${opinion.impactOpinion}`);
943
+ }
944
+
945
+ if (opinion.suggestedFix) {
946
+ lines.push(`- Suggested fix opinion: ${opinion.suggestedFix}`);
947
+ }
948
+
949
+ if (opinion.matchedKey) {
950
+ lines.push(`- Possible existing cluster: \`${opinion.matchedKey}\` (human merge decision required)`);
951
+ }
952
+
953
+ if (opinion.transient) {
954
+ lines.push("- May be transient or user error; review the original observation.");
955
+ }
956
+ }
957
+
887
958
  if (group.scenarios.length) {
888
959
  lines.push("", "**Observed needs**", ...group.scenarios.map((value) => `- ${markdownText(value)}`));
889
960
  }
@@ -1338,12 +1409,20 @@ export async function recordCapabilityGap(options) {
1338
1409
  runId,
1339
1410
  cwd,
1340
1411
  gap,
1412
+ assessment,
1341
1413
  signal,
1342
1414
  now = new Date().toISOString(),
1343
1415
  maxEventFileBytes = MAX_EVENT_FILE_BYTES,
1416
+ isCurrent = () => true,
1417
+ assessmentIsCurrent = () => true,
1418
+ onRecorded = () => {},
1344
1419
  } = options;
1345
1420
 
1346
1421
  return withStateLock(stateDir, signal, async () => {
1422
+ if (signal?.aborted || !isCurrent()) {
1423
+ throw new Error("The task changed before the report could be recorded");
1424
+ }
1425
+
1347
1426
  if (readCollectionMode(stateDir) !== "on") {
1348
1427
  throw new Error("Local wishlist collection must be explicitly on before recording observations");
1349
1428
  }
@@ -1371,6 +1450,10 @@ export async function recordCapabilityGap(options) {
1371
1450
  resolveAlias(event.observedKey ?? event.canonicalKey, aliases) === canonicalKey &&
1372
1451
  event.runHash === runHash,
1373
1452
  );
1453
+ const advisory = sanitizeAssessment(
1454
+ assessmentIsCurrent() ? assessment : undefined,
1455
+ new Set(canonicalizeEvents(parsed.events, decisionData.decisions).map((event) => event.canonicalKey)),
1456
+ );
1374
1457
  if (!duplicate) {
1375
1458
  const event = {
1376
1459
  schema: 1,
@@ -1381,6 +1464,7 @@ export async function recordCapabilityGap(options) {
1381
1464
  runHash,
1382
1465
  projectHash,
1383
1466
  ...sanitized,
1467
+ ...(advisory ? { assessment: advisory } : {}),
1384
1468
  ...(regression ? { regression: true } : {}),
1385
1469
  };
1386
1470
  parsed.bytes = appendBounded(
@@ -1391,6 +1475,7 @@ export async function recordCapabilityGap(options) {
1391
1475
  "Tool wishlist event log",
1392
1476
  );
1393
1477
  parsed.events.push(event);
1478
+ onRecorded({ assessmentRecorded: advisory !== undefined });
1394
1479
  }
1395
1480
 
1396
1481
  writeReport(
@@ -1407,6 +1492,7 @@ export async function recordCapabilityGap(options) {
1407
1492
 
1408
1493
  return {
1409
1494
  duplicate,
1495
+ assessmentRecorded: !duplicate && advisory !== undefined,
1410
1496
  regression,
1411
1497
  resolved: priorState === "retired",
1412
1498
  canonicalKey,
@@ -31,6 +31,7 @@ import {
31
31
  setCollectionMode,
32
32
  wishlistSourceRootIdentity,
33
33
  wishlistSourceRootSalt,
34
+ wishlistTriageCandidates,
34
35
  } from "./core.mjs";
35
36
  import { VALIDATOR_CATALOG } from "./validators.mjs";
36
37
  import { isValidValidatorName, validateCapabilityRegistry } from "./registry.mjs";
@@ -608,9 +609,19 @@ export default function toolWishlist(pi: ExtensionAPI) {
608
609
  }
609
610
  };
610
611
 
612
+ // Earlier hooks may await objective lookup before our input/start hook runs. Invalidate
613
+ // pending reports synchronously when that lookup's owner observes the task change.
614
+ pi.events?.on?.("specpi:task-changing", () => {
615
+ activeRunId = randomUUID();
616
+ });
611
617
  pi.on("before_agent_start", async () => {
612
618
  activeRunId = randomUUID();
613
619
  });
620
+ pi.on("input", (event: any) => {
621
+ if (event.streamingBehavior === "steer" && event.source !== "extension") {
622
+ activeRunId = randomUUID();
623
+ }
624
+ });
614
625
 
615
626
  const displayMarkdown = async (markdown: string, reportPath: string, ctx: any) => {
616
627
  const display = truncateReportDisplay(markdown);
@@ -642,7 +653,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
642
653
  name: "report_capability_gap",
643
654
  label: "Report Capability Gap",
644
655
  description:
645
- "Privately record a material, reusable capability gap in SpecPi's local wishlist. Report only after reasonable existing tools or workarounds proved insufficient. Do not use for transient failures, command mistakes, credentials or permissions the user must supply, ordinary project-specific work, or speculative nice-to-haves. Never include secrets, source code, full commands, file paths, URLs with private data, or user prompt text. Report a gap at most once per user task. Collection requires an explicit local on/off decision and never uploads data.",
656
+ "Privately record a material, reusable capability gap in SpecPi's local wishlist. Report only after reasonable existing tools or workarounds proved insufficient. Do not use for transient failures, command mistakes, credentials or permissions the user must supply, ordinary project-specific work, or speculative nice-to-haves. Never include secrets, source code, full commands, file paths, URLs with private data, or user prompt text. Report a gap at most once per user task. Collection requires an explicit local on/off decision. Only a separately enabled and consented Jev advisor may transmit redacted report samples.",
646
657
  promptSnippet: "Record recurring, generalizable capability friction without interrupting the user task",
647
658
  promptGuidelines: [
648
659
  "Use report_capability_gap only for a material and generalizable missing capability after reasonable existing tools or workarounds have proved insufficient.",
@@ -685,6 +696,27 @@ export default function toolWishlist(pi: ExtensionAPI) {
685
696
  { additionalProperties: false },
686
697
  ),
687
698
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
699
+ // Bind before consent or root discovery can yield to another task or branch.
700
+ const sessionId = ctx.sessionManager.getSessionId();
701
+ const generation = improvementLifecycleGeneration;
702
+ const reportRun = activeRunId;
703
+ const contractIdentity = () =>
704
+ JSON.stringify(
705
+ branchEntries(ctx).findLast((entry: any) => entry.customType === TASK_CONTRACT_ENTRY)?.data ?? null,
706
+ );
707
+ const originalContract = contractIdentity();
708
+ const isCurrent = () =>
709
+ !signal?.aborted &&
710
+ generation === improvementLifecycleGeneration &&
711
+ sessionId === ctx.sessionManager.getSessionId() &&
712
+ reportRun === activeRunId &&
713
+ originalContract === contractIdentity();
714
+ const assertCurrent = () => {
715
+ if (!isCurrent()) {
716
+ throw new Error("The task or session changed before the report could be recorded");
717
+ }
718
+ };
719
+
688
720
  let mode = readCollectionMode(stateDir);
689
721
  if (mode === "undecided") {
690
722
  if (!ctx.hasUI) {
@@ -701,8 +733,9 @@ export default function toolWishlist(pi: ExtensionAPI) {
701
733
 
702
734
  const enabled = await ctx.ui.confirm(
703
735
  "Enable local capability-gap collection?",
704
- "SpecPi stores sanitized summaries and salted task, session, and project hashes locally. It never uploads them. You can change this later with /wishlist on or /wishlist off.",
736
+ "SpecPi stores sanitized summaries and salted task, session, and project hashes locally. Collection itself never uploads them; the optional Jev advisor requires separate transmission consent. Change collection with /wishlist on or /wishlist off.",
705
737
  );
738
+ assertCurrent();
706
739
  mode = enabled ? "on" : "off";
707
740
  await setCollectionMode({ stateDir, mode, signal });
708
741
  }
@@ -720,15 +753,52 @@ export default function toolWishlist(pi: ExtensionAPI) {
720
753
  }
721
754
 
722
755
  const contractRoot = await resolveTaskContractRoot(pi, ctx.cwd, signal);
756
+ assertCurrent();
723
757
  const contract = branchTaskContract(ctx, contractRoot, { tolerateMalformed: true });
724
- const result = await recordCapabilityGap({
725
- stateDir,
726
- sessionId: ctx.sessionManager.getSessionId(),
727
- runId: contract?.id ?? activeRunId,
728
- cwd: ctx.cwd,
758
+ const runId = contract?.id ?? reportRun;
759
+ const record = (assessment?: any, authority: any = {}) => {
760
+ assertCurrent();
761
+
762
+ return recordCapabilityGap({
763
+ stateDir,
764
+ sessionId,
765
+ runId,
766
+ cwd: ctx.cwd,
767
+ gap: params,
768
+ assessment,
769
+ signal,
770
+ isCurrent,
771
+ assessmentIsCurrent: authority.isCurrent,
772
+ onRecorded: authority.onRecorded,
773
+ });
774
+ };
775
+
776
+ // A trusted extension callback, not a field in model-authored arguments. No advisor
777
+ // is required, and the original report is never overwritten by its second opinion.
778
+ const pending: Promise<any>[] = [];
779
+ pi.events?.emit("specpi:gap-triage", {
729
780
  gap: params,
781
+ ctx,
730
782
  signal,
783
+ record,
784
+ isCurrent: () => isCurrent() && readCollectionMode(stateDir) === "on",
785
+ existing: () => wishlistTriageCandidates(stateDir),
786
+ reply: (value: Promise<any>) => pending.push(value),
731
787
  });
788
+ // Classifier failures leave normal collection available; `record` still checks its
789
+ // task binding and the collection switch before any write.
790
+ const triage = pending.length === 1 ? await pending[0].catch(() => undefined) : undefined;
791
+ if (triage?.blocked) {
792
+ throw new Error(
793
+ "This report appears to contain sensitive or machine-specific details. Remove the specifics and report it again.",
794
+ );
795
+ }
796
+
797
+ if (triage?.cancelled) {
798
+ throw new Error("The session changed before the report could be recorded");
799
+ }
800
+
801
+ const result = triage?.stored ?? (await record());
732
802
  const disposition = result.duplicate
733
803
  ? "Already recorded for this task"
734
804
  : result.regression
@@ -1380,7 +1450,10 @@ export default function toolWishlist(pi: ExtensionAPI) {
1380
1450
 
1381
1451
  if (action === "on" || action === "off") {
1382
1452
  await setCollectionMode({ stateDir, mode: action });
1383
- ctx.ui.notify(`Local wishlist collection is ${action}. No data is uploaded.`, "info");
1453
+ ctx.ui.notify(
1454
+ `Local wishlist collection is ${action}. Jev transmission requires its own switch and consent.`,
1455
+ "info",
1456
+ );
1384
1457
 
1385
1458
  return;
1386
1459
  }
@@ -366,6 +366,29 @@ export default function workflowControls(pi: ExtensionAPI) {
366
366
  sessionRestore = restoreSession(ctx).catch(() => {});
367
367
  };
368
368
 
369
+ // Serve the active contract structurally, independent of before_agent_start load order.
370
+ // The advisor receives only the objective, never a branch transcript or rendered headings.
371
+ pi.events.on("specpi:task-objective", (request: any) => {
372
+ request.reply(
373
+ (async () => {
374
+ const ctx = request.ctx as ExtensionContext;
375
+ const origin = captureSession(ctx);
376
+ await sessionRestore;
377
+ if (!sessionIsCurrent(origin, ctx)) {
378
+ return undefined;
379
+ }
380
+
381
+ try {
382
+ const contract = readCurrentTaskContract(ctx, scope.root);
383
+
384
+ return contract ? { objective: contract.objective, digest: contract.digest } : undefined;
385
+ } catch {
386
+ return undefined;
387
+ }
388
+ })(),
389
+ );
390
+ });
391
+
369
392
  // Web access ships hidden. A missing or unreadable preference means off, and the
370
393
  // gate only ever touches its own four tool names.
371
394
  let webAccessEnabled = loadStartupActivation();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Scope control and a human-selected harness improvement loop for Pi",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -83,7 +83,7 @@
83
83
  "check:package": "node scripts/check-package.mjs",
84
84
  "check:pi-package": "node scripts/check-pi-package.mjs",
85
85
  "check:base": "node scripts/check-base-packages.mjs",
86
- "check:site": "node scripts/check-site.mjs",
86
+ "check:site": "node scripts/jev-diagrams.mjs --check && node scripts/check-site.mjs",
87
87
  "check:browser-qa": "npm --prefix packages/browser-qa run check",
88
88
  "check:delegation": "npm --prefix packages/delegation run check",
89
89
  "check:experiments": "npm --prefix packages/experiments run check",
@@ -62,7 +62,6 @@ const resourcePaths = [
62
62
  "extensions/jev-advisor/questions/retention.mjs",
63
63
  "extensions/jev-advisor/questions/gap.mjs",
64
64
  "extensions/jev-advisor/questions/sources.mjs",
65
- "extensions/jev-advisor/questions/progress.mjs",
66
65
  "extensions/jev-advisor/questions/untrusted.mjs",
67
66
  "extensions/jev-advisor/questions/capabilities.mjs",
68
67
  "skills/specpi-improve/SKILL.md",
@@ -1,195 +0,0 @@
1
- // System 5: notice that a session has stopped making progress, while it can still be helped.
2
- //
3
- // This is the only system aimed at turns rather than at input tokens, and turns are where the money
4
- // is on the hard tiers. Splitting recorded cost three ways: at tier 3 output is 59% of spend, at
5
- // tier 4 66%, at tier 5 56%. Output is bought by turns, and every other system here targets
6
- // input. At tier 4 `specpi-jev` runs cheaper per turn than `specpi-default` -- $0.000538 against
7
- // $0.000613 -- and costs more per attempt anyway, because it takes 29.4 turns against 24.8.
8
- //
9
- // The evidence that turns are wasted rather than merely numerous is in the same runs.
10
- // `specpi-default` invoked `webqa` 199 times across 30 turns on `t4-browser-triage` and still
11
- // failed. Pi stock abandoned after two tries and burned the full 600-second timeout on two tier-4
12
- // tasks, scoring zero both times. A turn costs 4-7 seconds and $0.0005-$0.0011; a Jev call costs
13
- // about 300ms and $0.0000105. Preventing one timeout pays for roughly two thousand calls.
14
- //
15
- // LOCAL STATE FIRST, which is the standing rule and the reason this is affordable. Local state
16
- // cannot answer "is this session stuck" -- that needs a judgement about whether the work is going
17
- // anywhere -- but it answers "is that question worth asking", and it answers it cheaply. Nothing is
18
- // sent unless a repeated tool signature, a run of errors or a stretch of turns without a file
19
- // change has already made the session look suspicious.
20
- //
21
- // It is an append, so it passes the standing cache rule: the line goes on the end of the transcript
22
- // at a turn boundary and invalidates no prefix. It is written once and never retracted, because
23
- // retracting it would mean rewriting the thing it was appended to.
24
- //
25
- // The plan named `deliverAs: "nextTurn"` for that append. Pi documents that mode as "queued for
26
- // next user prompt, does not interrupt or trigger anything", and an unattended session has exactly
27
- // one user prompt -- so a nextTurn message would never arrive, in precisely the case the argument
28
- // for this system rests on. The caller uses "steer" instead: delivered after the current tool calls
29
- // finish and before the next model request, which is the same append at the same boundary and is
30
- // actually read. `triggerTurn` stays off, so this can never add a turn of its own.
31
-
32
- import { choice, noul } from "../client.mjs";
33
- import { choiceValue, nounTrue } from "../gate.mjs";
34
- import { compact } from "../sanitize.mjs";
35
-
36
- /**
37
- * The same taxonomy `scripts/jev-triage.mjs` uses offline. It lives here, in the shipped extension,
38
- * and the script imports it: the corpus that calibrates this system and the enum this system asks
39
- * against have to be the same object, or the published failure-mode distribution is measuring
40
- * something the session never asks.
41
- */
42
- export const FAILURE_MODES = Object.freeze({
43
- "gave-up-on-fault": "Met an injected command failure and stopped instead of retrying",
44
- "turn-cap": "Ran out of turns or requests while still working",
45
- timeout: "Exceeded the wall-clock limit",
46
- "wrong-approach": "Worked steadily but solved the wrong problem",
47
- "misread-requirement": "Produced output that misses a stated requirement",
48
- "scope-violation": "Changed files it was told to leave alone",
49
- "tool-error-loop": "Repeated the same failing tool call without progress",
50
- "harness-error": "The harness itself crashed or could not start",
51
- unknown: "Not determinable from what was recorded",
52
- });
53
-
54
- /** Modes a session can still act on. A timeout or a crashed harness is not advice, it is an epitaph. */
55
- export const ACTIONABLE_MODES = Object.freeze(
56
- new Set(["wrong-approach", "misread-requirement", "tool-error-loop", "gave-up-on-fault", "scope-violation"]),
57
- );
58
-
59
- /** Tools whose success means the worktree changed. Used only to decide whether anything happened. */
60
- export const MUTATING_TOOLS = Object.freeze(
61
- new Set(["write", "edit", "multi_edit", "apply_patch", "create_file", "str_replace"]),
62
- );
63
-
64
- /** Turns without a single successful mutation before that counts as a reason to look. */
65
- export const STALE_TURNS = 4;
66
- const REPEAT_SIGNATURES = 2;
67
- const CONSECUTIVE_ERRORS = 3;
68
-
69
- /**
70
- * Turns to stay quiet after asking. Conditions persist for many turns at a time, so without this
71
- * the same unchanged situation is re-asked every turn until the budget runs out.
72
- */
73
- export const ASK_COOLDOWN_TURNS = 4;
74
-
75
- /**
76
- * A tool call reduced to something comparable. The arguments matter -- reading two different files
77
- * twice is work, reading one file twice is a loop -- but their contents do not, so this keeps a
78
- * short normalized form rather than the payload.
79
- */
80
- export function signature(toolName, input) {
81
- return compact(`${toolName}:${JSON.stringify(input ?? {})}`, 120);
82
- }
83
-
84
- /**
85
- * Whether local state alone already justifies the question. Returns the reasons rather than a bare
86
- * boolean, because they go into the state as the grounds for asking and a caller logging "asked"
87
- * without "why" cannot audit the budget afterwards.
88
- *
89
- * One reason is not enough, and that is a correction rather than a precaution. The first version
90
- * asked whenever any single signal fired, and a live run of `t3-cascade-ledger` spent all twelve
91
- * calls of its budget on a session that finished with a score of 0.978. A 120-step repair chain
92
- * re-runs the same verification command constantly, so "a tool signature repeated" is its normal
93
- * condition, not a symptom. The same is true of "no file written for four turns" during a long
94
- * read: a research task looks identical to a stuck one from that signal alone.
95
- *
96
- * So a repeated call or a quiet stretch must coincide with something else before it is worth
97
- * asking about. A run of consecutive errors stands alone, because nothing healthy produces three
98
- * failures in a row.
99
- */
100
- export function suspicious(history) {
101
- const reasons = [];
102
- const signatures = history?.signatures ?? [];
103
- const counts = new Map();
104
- for (const item of signatures) {
105
- counts.set(item, (counts.get(item) ?? 0) + 1);
106
- }
107
-
108
- const repeated = [...counts.entries()].filter(([, count]) => count >= REPEAT_SIGNATURES);
109
- if (repeated.length > 0) {
110
- reasons.push("repeated-tool-call");
111
- }
112
-
113
- const errors = (history?.consecutiveErrors ?? 0) >= CONSECUTIVE_ERRORS;
114
- if (errors) {
115
- reasons.push("consecutive-errors");
116
- }
117
-
118
- if ((history?.turnsSinceChange ?? 0) >= STALE_TURNS) {
119
- reasons.push("no-file-change");
120
- }
121
-
122
- // Conditions persist across turns, so a verdict of "not stuck" is still the right answer three
123
- // turns later and asking again buys nothing. The cooldown is what stops one situation being
124
- // charged for repeatedly.
125
- const cooling = Number.isFinite(history?.askedAtTurn)
126
- ? (history.turn ?? 0) - history.askedAtTurn < ASK_COOLDOWN_TURNS
127
- : false;
128
-
129
- return {
130
- ask: !cooling && (errors || reasons.length >= 2),
131
- reasons,
132
- repeatedSignatures: repeated.length,
133
- cooling,
134
- };
135
- }
136
-
137
- /** Shape and counts. No tool output, no file contents, no arguments beyond a normalized signature. */
138
- export function buildInput({ history, objective, reasons }) {
139
- return {
140
- objective: compact(objective ?? "", 180),
141
- turn: history?.turn ?? 0,
142
- reasons,
143
- turnsSinceFileChange: history?.turnsSinceChange ?? 0,
144
- consecutiveErrors: history?.consecutiveErrors ?? 0,
145
- distinctTools: [...new Set((history?.tools ?? []).slice(-12))].slice(0, 12),
146
- repeatedCalls: suspicious(history).repeatedSignatures,
147
- recentErrors: (history?.errors ?? []).slice(-4).map((item) => compact(item, 80)),
148
- filesChanged: history?.filesChanged ?? 0,
149
- };
150
- }
151
-
152
- export function questions() {
153
- return {
154
- // Deliberately two questions rather than one. "Is it stuck" is the decision; "which mode"
155
- // is what makes a fixed remedy line possible without any model-written prose.
156
- is_stuck: noul("This session has stopped making progress and will not finish without changing approach"),
157
- failure_mode: choice("If this session is going to fail, why?", FAILURE_MODES),
158
- needs_human: noul("A person would have to answer something before this session could continue"),
159
- };
160
- }
161
-
162
- /** One fixed line per mode. Code-written, so nothing the model produced reaches the transcript. */
163
- const REMEDIES = Object.freeze({
164
- "wrong-approach":
165
- "Progress check: the last few turns have not moved the task forward. Re-read the objective and state, in one line, what the current approach is meant to achieve before continuing.",
166
- "misread-requirement":
167
- "Progress check: the work so far may not match what was asked. Re-read the requirements and list which ones are satisfied and which are not, before making further changes.",
168
- "tool-error-loop":
169
- "Progress check: the same tool call has failed repeatedly. Stop retrying it and either fix the cause or use a different approach.",
170
- "gave-up-on-fault":
171
- "Progress check: a command failed and was not retried. Transient failures are expected here; retry it before concluding the task cannot be done.",
172
- "scope-violation":
173
- "Progress check: files outside the permitted scope may have been changed. Check what has been modified against what the task allows.",
174
- });
175
-
176
- /**
177
- * The nudge, or nothing. Requires a confident stuck verdict AND a gated mode with a remedy: a
178
- * confident "stuck" with no idea why would produce a message that says only that something is
179
- * wrong, which is the kind of unfalsifiable hint the standing rule against risk hints rejects.
180
- *
181
- * `needsHuman` decides who the nudge is for. If the session is blocked on something only a person
182
- * can answer -- a missing credential, an ambiguous requirement -- then telling the model to try
183
- * harder is the wrong recipient and costs a turn to say nothing. The caller reads it to suppress
184
- * the message path while still surfacing the notification.
185
- */
186
- export function decide(answers) {
187
- const stuck = nounTrue(answers?.is_stuck, "progress");
188
- const mode = choiceValue(answers?.failure_mode, "progress");
189
- const needsHuman = nounTrue(answers?.needs_human, "progress");
190
- if (!stuck || !mode || !ACTIONABLE_MODES.has(mode) || !REMEDIES[mode]) {
191
- return { nudge: undefined, stuck, mode, needsHuman };
192
- }
193
-
194
- return { nudge: REMEDIES[mode], stuck, mode, needsHuman };
195
- }