jeopi-cli 16.2.22 → 16.2.24

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +2565 -2524
  3. package/dist/types/advisor/runtime.d.ts +9 -1
  4. package/dist/types/cli/update-cli.d.ts +37 -0
  5. package/dist/types/config/settings-schema.d.ts +12 -1
  6. package/dist/types/extensibility/plugins/loader.d.ts +4 -4
  7. package/dist/types/extensibility/plugins/trust.d.ts +30 -0
  8. package/dist/types/goals/completion-evidence.d.ts +21 -0
  9. package/dist/types/goals/runtime.d.ts +1 -1
  10. package/dist/types/goals/state.d.ts +3 -0
  11. package/dist/types/goals/tools/goal-tool.d.ts +2 -0
  12. package/dist/types/main.d.ts +20 -0
  13. package/dist/types/mnemopi/state.d.ts +1 -0
  14. package/dist/types/session/agent-session.d.ts +3 -1
  15. package/dist/types/task/critic-gate.d.ts +34 -0
  16. package/dist/types/tools/builtin-names.d.ts +1 -1
  17. package/dist/types/tools/index.d.ts +6 -1
  18. package/dist/types/tools/subagent.d.ts +70 -0
  19. package/dist/types/tools/subagent.test.d.ts +1 -0
  20. package/package.json +12 -12
  21. package/src/advisor/__tests__/advisor.test.ts +173 -0
  22. package/src/advisor/runtime.ts +66 -2
  23. package/src/cli/update-cli.ts +144 -0
  24. package/src/config/settings-schema.ts +14 -2
  25. package/src/extensibility/plugins/loader.ts +35 -9
  26. package/src/extensibility/plugins/trust.ts +133 -0
  27. package/src/goals/completion-evidence.ts +73 -0
  28. package/src/goals/runtime.ts +6 -1
  29. package/src/goals/state.ts +3 -0
  30. package/src/goals/tools/goal-tool.ts +16 -1
  31. package/src/internal-urls/docs-index.generated.txt +2 -2
  32. package/src/main.ts +56 -10
  33. package/src/memories/index.ts +21 -1
  34. package/src/mnemopi/state.ts +10 -2
  35. package/src/modes/interactive-mode.ts +21 -0
  36. package/src/prompts/memories/read-path.md +3 -1
  37. package/src/prompts/system/safety-kernel.md +17 -0
  38. package/src/prompts/system/tool-call-cycle-redirect.md +5 -0
  39. package/src/prompts/tools/goal.md +1 -1
  40. package/src/prompts/tools/subagent.md +29 -0
  41. package/src/session/agent-session.ts +68 -19
  42. package/src/slash-commands/builtin-registry.ts +18 -0
  43. package/src/system-prompt.ts +14 -2
  44. package/src/task/critic-gate.ts +46 -0
  45. package/src/task/index.ts +63 -2
  46. package/src/tools/builtin-names.ts +1 -0
  47. package/src/tools/index.ts +9 -1
  48. package/src/tools/subagent.test.ts +285 -0
  49. package/src/tools/subagent.ts +582 -0
@@ -50,6 +50,8 @@ export interface AdvisorRuntimeHost {
50
50
  beginAdvisorUpdate?(): void;
51
51
  /** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
52
52
  notifyFailure?(error: unknown): void;
53
+ /** Surface a one-time notice that thinking was stripped from advisor renders after a provider refusal. */
54
+ notifyThinkingStripped?(): void;
53
55
  }
54
56
 
55
57
  interface PendingDelta {
@@ -57,6 +59,28 @@ interface PendingDelta {
57
59
  turns: number;
58
60
  }
59
61
 
62
+ /** Construction-time tuning for {@link AdvisorRuntime}. */
63
+ export interface AdvisorRuntimeOptions {
64
+ /** Render the primary's thinking blocks into advisor deltas (default true). */
65
+ includeThinking?: boolean;
66
+ }
67
+
68
+ /**
69
+ * Match the stable `Refusal` / `Refusal (…)` message prefix pi-ai emits for a
70
+ * provider classifier refusal (`stop_details.type === "refusal"` mapped in
71
+ * `packages/ai/src/providers/anthropic.ts`), e.g. Anthropic Fable's
72
+ * `Refusal (reasoning_extraction): This request was blocked …`. Refusals are
73
+ * payload-shape-bound: per Anthropic's refusal semantics, replaying the
74
+ * identical payload predictably re-refuses, so the drain loop must degrade the
75
+ * payload instead of blind-retrying it.
76
+ */
77
+ const REFUSAL_MESSAGE_PREFIX = /^(?:Error:\s*)?Refusal(?:\s*\(|:|$)/;
78
+
79
+ export function isRefusalError(error: unknown): boolean {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ return REFUSAL_MESSAGE_PREFIX.test(message.trim());
82
+ }
83
+
60
84
  interface CatchupWaiter {
61
85
  threshold: number;
62
86
  resolve: () => void;
@@ -86,11 +110,24 @@ export class AdvisorRuntime {
86
110
  #epoch = 0;
87
111
  disposed = false;
88
112
 
113
+ /**
114
+ * Render primary thinking into deltas. Doubles as the refusal-degrade latch:
115
+ * starts `true` (or whatever the constructor option says) and flips to
116
+ * `false` exactly once, the first time a classifier refusal is caught — see
117
+ * the `#drain` catch block. That single flip is sufficient to both change
118
+ * future renders AND guard against re-triggering the degrade path, so no
119
+ * separate one-shot flag is needed alongside it.
120
+ */
121
+ #includeThinking: boolean;
122
+
89
123
  constructor(
90
124
  private readonly agent: AdvisorAgent,
91
125
  private readonly host: AdvisorRuntimeHost,
92
126
  private readonly retryDelayMs = 1000,
93
- ) {}
127
+ options: AdvisorRuntimeOptions = {},
128
+ ) {
129
+ this.#includeThinking = options.includeThinking ?? true;
130
+ }
94
131
 
95
132
  get backlog(): number {
96
133
  return this.#backlog;
@@ -205,7 +242,7 @@ export class AdvisorRuntime {
205
242
  const obfuscator = this.host.obfuscator;
206
243
  const formattedDelta = obfuscator?.hasSecrets() ? obfuscateAdvisorDelta(obfuscator, delta) : delta;
207
244
  const md = formatSessionHistoryMarkdown(formattedDelta, {
208
- includeThinking: true,
245
+ includeThinking: this.#includeThinking,
209
246
  includeToolIntent: true,
210
247
  watchedRoles: true,
211
248
  expandPrimaryContext: true,
@@ -352,6 +389,33 @@ export class AdvisorRuntime {
352
389
  if (this.#epoch !== epoch) continue;
353
390
  this.#rollbackFailedTurn(messageSnapshot);
354
391
  logger.debug("advisor turn failed", { err: String(err) });
392
+ // Classifier refusals (e.g. Anthropic Fable's `reasoning_extraction`,
393
+ // which flags harnesses that feed model thinking back as text) are
394
+ // payload-shape-bound: re-sending the identical batch predictably
395
+ // re-refuses. Degrade once — latch thinking off, re-prime, and requeue
396
+ // a re-rendered batch without thinking. Flipping `#includeThinking` to
397
+ // `false` here doubles as the one-shot guard: a refusal after the
398
+ // strip fails the `this.#includeThinking` check below and falls
399
+ // through to the normal consecutive-failure watchdog, so this cannot
400
+ // loop.
401
+ if (this.#includeThinking && isRefusalError(err)) {
402
+ this.#includeThinking = false;
403
+ const queuedTurns = this.#pending.reduce((sum, b) => sum + b.turns, 0);
404
+ this.#resetAdvisorContext(false, false);
405
+ const stripped = this.#renderDelta(this.#latestMessages);
406
+ try {
407
+ this.host.notifyThinkingStripped?.();
408
+ } catch (notifyErr) {
409
+ logger.warn("advisor thinking-strip notification failed", { err: String(notifyErr) });
410
+ }
411
+ if (stripped !== null) {
412
+ this.#pending.unshift({ text: stripped, turns: finalTurns + queuedTurns });
413
+ } else {
414
+ this.#backlog = Math.max(0, this.#backlog - (finalTurns + queuedTurns));
415
+ this.#notifyWaiters();
416
+ }
417
+ continue;
418
+ }
355
419
  this.#consecutiveFailures++;
356
420
  if (this.#consecutiveFailures >= 3) {
357
421
  logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
@@ -303,6 +303,130 @@ export async function fetchNewerPublishedVersion(currentVersion: string): Promis
303
303
  }
304
304
  }
305
305
 
306
+ /** Minimal published GitHub release shape used by the update-notes and registry-lag helpers. */
307
+ export interface GitHubReleaseSummary {
308
+ version: string;
309
+ body: string;
310
+ }
311
+
312
+ /** Cap on printed release-note lines so a many-versions-behind update does not flood the terminal. */
313
+ const RELEASE_NOTES_MAX_LINES = 40;
314
+
315
+ /**
316
+ * Parse a GitHub releases API response into {@link GitHubReleaseSummary}
317
+ * entries, keeping only published (non-draft, non-prerelease) releases whose
318
+ * tag matches the `vX.Y.Z` convention. Split out from {@link fetchGitHubReleases}
319
+ * so the response-shape handling is independently testable without a network
320
+ * call.
321
+ */
322
+ export function parseGitHubReleases(data: unknown): GitHubReleaseSummary[] {
323
+ if (!Array.isArray(data)) return [];
324
+ const releases: GitHubReleaseSummary[] = [];
325
+ for (const entry of data as Array<{
326
+ tag_name?: unknown;
327
+ body?: unknown;
328
+ draft?: unknown;
329
+ prerelease?: unknown;
330
+ }>) {
331
+ if (entry.draft === true || entry.prerelease === true) continue;
332
+ const tag = typeof entry.tag_name === "string" ? entry.tag_name : "";
333
+ const match = tag.match(/^v(\d+\.\d+\.\d+)$/);
334
+ if (!match) continue;
335
+ releases.push({ version: match[1], body: typeof entry.body === "string" ? entry.body : "" });
336
+ }
337
+ return releases;
338
+ }
339
+
340
+ /**
341
+ * Fetch recent published releases for {@link REPO} from the GitHub API.
342
+ *
343
+ * Best-effort: `jeopi update` must never fail because release notes are
344
+ * unreachable, so network errors, rate limits, and shape surprises all
345
+ * resolve to an empty list. Only called on explicit `jeopi update` runs —
346
+ * the frequent startup notice keeps using the npm registry to avoid
347
+ * unauthenticated GitHub rate limits.
348
+ */
349
+ async function fetchGitHubReleases(): Promise<GitHubReleaseSummary[]> {
350
+ try {
351
+ const response = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, {
352
+ headers: { accept: "application/vnd.github+json", "user-agent": `${APP_NAME}-update` },
353
+ });
354
+ if (!response.ok) return [];
355
+ return parseGitHubReleases(await response.json());
356
+ } catch {
357
+ return [];
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Collect release-note bodies for versions in `(currentVersion, targetVersion]`,
363
+ * newest first. Backs the "what changed" block `jeopi update` prints under the
364
+ * "New version available" line, so a user several versions behind sees every
365
+ * intervening release, not just the newest body.
366
+ */
367
+ export function selectReleaseNotesInRange(
368
+ releases: readonly GitHubReleaseSummary[],
369
+ currentVersion: string,
370
+ targetVersion: string,
371
+ ): GitHubReleaseSummary[] {
372
+ return releases
373
+ .filter(
374
+ release =>
375
+ compareVersions(release.version, currentVersion) > 0 &&
376
+ compareVersions(release.version, targetVersion) <= 0,
377
+ )
378
+ .sort((a, b) => compareVersions(b.version, a.version));
379
+ }
380
+
381
+ /**
382
+ * Render selected release notes as plain indented text, capped at `maxLines`.
383
+ * HTML comments (release template scaffolding) are stripped; a truncated
384
+ * rendering ends with a pointer at the releases page.
385
+ */
386
+ export function formatReleaseNotes(
387
+ notes: readonly GitHubReleaseSummary[],
388
+ maxLines: number = RELEASE_NOTES_MAX_LINES,
389
+ ): string {
390
+ const lines: string[] = [];
391
+ for (const note of notes) {
392
+ const body = note.body
393
+ .replace(/\r\n/g, "\n")
394
+ .replace(/<!--[\s\S]*?-->/g, "")
395
+ .trim();
396
+ lines.push(`v${note.version}`);
397
+ for (const bodyLine of body.split("\n")) {
398
+ if (body.length === 0) break;
399
+ lines.push(` ${bodyLine}`);
400
+ }
401
+ lines.push("");
402
+ }
403
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
404
+ if (lines.length > maxLines) {
405
+ lines.length = maxLines;
406
+ lines.push(` … full notes: https://github.com/${REPO}/releases`);
407
+ }
408
+ return lines.join("\n");
409
+ }
410
+
411
+ /**
412
+ * Detect a published GitHub release the npm registry has not served yet.
413
+ *
414
+ * When the release pipeline dies between the GitHub Release and the npm
415
+ * publish job (or npm credentials are missing so the publish silently
416
+ * no-ops), the npm `latest` this updater trusts stays behind while GitHub
417
+ * advances — and `jeopi update` would report "Already up to date" with no
418
+ * hint that a newer release exists. Returns the newest GitHub version
419
+ * strictly above `npmVersion`, else `undefined`.
420
+ */
421
+ export function detectRegistryLag(npmVersion: string, releases: readonly GitHubReleaseSummary[]): string | undefined {
422
+ let newest: string | undefined;
423
+ for (const release of releases) {
424
+ if (compareVersions(release.version, npmVersion) <= 0) continue;
425
+ if (newest === undefined || compareVersions(release.version, newest) > 0) newest = release.version;
426
+ }
427
+ return newest;
428
+ }
429
+
306
430
  /**
307
431
  * Compare semver versions. Returns:
308
432
  * - negative if a < b
@@ -922,11 +1046,31 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
922
1046
 
923
1047
  if (comparison <= 0 && !opts.force) {
924
1048
  console.log(chalk.green(`${theme.status.success} Already up to date`));
1049
+ // npm is this updater's install channel, but GitHub Releases is where the
1050
+ // pipeline publishes first — diverging means the npm publish job is still
1051
+ // running or died (or npm credentials were missing and the publish
1052
+ // silently no-oped). Say so instead of leaving "Already up to date" as
1053
+ // the only signal after a visibly tagged release.
1054
+ const laggedVersion = detectRegistryLag(release.version, await fetchGitHubReleases());
1055
+ if (laggedVersion) {
1056
+ console.log(
1057
+ chalk.yellow(`GitHub has release v${laggedVersion}, but the npm registry still serves ${release.version}.`),
1058
+ );
1059
+ console.log(
1060
+ chalk.yellow(
1061
+ `The npm publish for v${laggedVersion} has not landed (release pipeline still running or failed); try again in a few minutes.`,
1062
+ ),
1063
+ );
1064
+ }
925
1065
  return;
926
1066
  }
927
1067
 
928
1068
  if (comparison > 0) {
929
1069
  console.log(chalk.cyan(`New version available: ${release.version}`));
1070
+ const notes = selectReleaseNotesInRange(await fetchGitHubReleases(), VERSION, release.version);
1071
+ if (notes.length > 0) {
1072
+ console.log(chalk.dim(`\n${formatReleaseNotes(notes)}\n`));
1073
+ }
930
1074
  } else {
931
1075
  console.log(chalk.yellow(`Forcing reinstall of ${release.version}`));
932
1076
  }
@@ -285,7 +285,7 @@ const EMPTY_STRING_ARRAY: string[] = [];
285
285
  const EMPTY_STRING_RECORD: Record<string, string> = {};
286
286
  const EMPTY_NUMBER_RECORD: Record<string, number> = {};
287
287
  const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
288
- const DEFAULT_TOOL_CALL_LOOP_EXEMPT_TOOLS: string[] = ["job", "irc"];
288
+ const DEFAULT_TOOL_CALL_LOOP_EXEMPT_TOOLS: string[] = ["job", "irc", "subagent"];
289
289
  const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
290
290
  const HINDSIGHT_RECALL_TYPES_DEFAULT: string[] = ["world", "experience"];
291
291
  export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
@@ -414,6 +414,18 @@ export const SETTINGS_SCHEMA = {
414
414
  condition: "advisorEnabled",
415
415
  },
416
416
  },
417
+ "advisor.includeThinking": {
418
+ type: "boolean",
419
+ default: true,
420
+ ui: {
421
+ tab: "model",
422
+ group: "Advisor",
423
+ label: "Advisor Sees Thinking",
424
+ description:
425
+ "Include the primary agent's thinking blocks in advisor transcript updates. Some provider classifiers (e.g. Anthropic's reasoning_extraction) refuse payloads that replay thinking as text; the advisor also auto-disables this for the session after such a refusal.",
426
+ condition: "advisorEnabled",
427
+ },
428
+ },
417
429
  "advisor.syncBacklog": {
418
430
  type: "enum",
419
431
  values: ["off", "1", "3", "5"] as const,
@@ -1592,7 +1604,7 @@ export const SETTINGS_SCHEMA = {
1592
1604
  tab: "interaction",
1593
1605
  group: "Startup & Updates",
1594
1606
  label: "Check for Updates",
1595
- description: "Check for jeopi updates on startup",
1607
+ description: "Check for jeopi updates on startup and periodically during long sessions",
1596
1608
  },
1597
1609
  },
1598
1610
 
@@ -6,11 +6,12 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import { getPluginsDir, getPluginsLockfile, isEnoent } from "jeopi-utils";
9
+ import { getPluginsDir, getPluginsLockfile, isEnoent, logger } from "jeopi-utils";
10
10
  import { getConfigDirPaths } from "../../config";
11
11
  import { resolveActiveProjectRegistryPath } from "../../discovery/helpers";
12
12
  import { installLegacyPiSpecifierShim } from "./legacy-pi-compat";
13
13
  import { normalizePluginRuntimeConfig } from "./runtime-config";
14
+ import { resolvePluginTrust } from "./trust";
14
15
  import type {
15
16
  InstalledPlugin,
16
17
  PluginManifest,
@@ -443,10 +444,35 @@ export function resolvePluginExtensionPaths(plugin: InstalledPlugin): string[] {
443
444
  // =============================================================================
444
445
 
445
446
  /**
446
- * Get all tool paths from all enabled plugins.
447
+ * `getEnabledPlugins` filtered down to plugins the user has trusted (or
448
+ * explicitly denied) via {@link resolvePluginTrust}. Plugin code executes via
449
+ * native `import()` with full `exec`/API access, so — unlike user-authored
450
+ * `.jeopi/tools/` etc., which are trusted by construction — third-party
451
+ * plugin code must clear a trust-on-first-use gate before any of its
452
+ * tools/hooks/commands/extensions are loaded. Untrusted/denied plugins are
453
+ * silently skipped here (not thrown): the plugin stays installed and its
454
+ * non-code capabilities (skills/rules, discovered separately) are unaffected.
447
455
  */
448
- export async function getAllPluginToolPaths(cwd: string): Promise<string[]> {
456
+ async function getTrustedEnabledPlugins(cwd: string): Promise<ScopedInstalledPlugin[]> {
449
457
  const plugins = await getEnabledPlugins(cwd);
458
+ if (plugins.length === 0) return plugins;
459
+ const trusted: ScopedInstalledPlugin[] = [];
460
+ for (const plugin of plugins) {
461
+ const isTrusted = await resolvePluginTrust({ name: plugin.name, version: plugin.version });
462
+ if (isTrusted) {
463
+ trusted.push(plugin);
464
+ } else {
465
+ logger.debug("plugin code load skipped: not trusted", { name: plugin.name, version: plugin.version });
466
+ }
467
+ }
468
+ return trusted;
469
+ }
470
+
471
+ /**
472
+ * Get all tool paths from all enabled *and trusted* plugins.
473
+ */
474
+ export async function getAllPluginToolPaths(cwd: string): Promise<string[]> {
475
+ const plugins = await getTrustedEnabledPlugins(cwd);
450
476
  const paths: string[] = [];
451
477
 
452
478
  for (const plugin of plugins) {
@@ -457,10 +483,10 @@ export async function getAllPluginToolPaths(cwd: string): Promise<string[]> {
457
483
  }
458
484
 
459
485
  /**
460
- * Get all hook paths from all enabled plugins.
486
+ * Get all hook paths from all enabled *and trusted* plugins.
461
487
  */
462
488
  export async function getAllPluginHookPaths(cwd: string): Promise<string[]> {
463
- const plugins = await getEnabledPlugins(cwd);
489
+ const plugins = await getTrustedEnabledPlugins(cwd);
464
490
  const paths: string[] = [];
465
491
 
466
492
  for (const plugin of plugins) {
@@ -471,10 +497,10 @@ export async function getAllPluginHookPaths(cwd: string): Promise<string[]> {
471
497
  }
472
498
 
473
499
  /**
474
- * Get all command paths from all enabled plugins.
500
+ * Get all command paths from all enabled *and trusted* plugins.
475
501
  */
476
502
  export async function getAllPluginCommandPaths(cwd: string): Promise<string[]> {
477
- const plugins = await getEnabledPlugins(cwd);
503
+ const plugins = await getTrustedEnabledPlugins(cwd);
478
504
  const paths: string[] = [];
479
505
 
480
506
  for (const plugin of plugins) {
@@ -485,10 +511,10 @@ export async function getAllPluginCommandPaths(cwd: string): Promise<string[]> {
485
511
  }
486
512
 
487
513
  /**
488
- * Get all extension module paths from all enabled plugins.
514
+ * Get all extension module paths from all enabled *and trusted* plugins.
489
515
  */
490
516
  export async function getAllPluginExtensionPaths(cwd: string): Promise<string[]> {
491
- const plugins = await getEnabledPlugins(cwd);
517
+ const plugins = await getTrustedEnabledPlugins(cwd);
492
518
  const paths: string[] = [];
493
519
 
494
520
  for (const plugin of plugins) {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Plugin trust gate — trust-on-first-use consent for third-party plugin code.
3
+ *
4
+ * `custom-tools`/`hooks`/`commands`/`extensions` loaders all execute arbitrary
5
+ * TypeScript via native `import()` with full `exec`/`pi` API access. User-authored
6
+ * tools under `.jeopi/tools/` etc. are trusted by construction (the user wrote or
7
+ * explicitly configured them). Plugin-installed code is different: it arrives via
8
+ * `bun install <name>` from a marketplace/npm registry, i.e. third-party code the
9
+ * user did not author. This module gates that path specifically.
10
+ *
11
+ * Mirrors the `report-tool-issue.ts` auto-QA consent design: a process-global cache,
12
+ * a disk-backed decision store, a single host-registered handler for the interactive
13
+ * popup, and default-deny when no handler is registered (headless/CI/non-interactive
14
+ * runs never silently execute unreviewed third-party code).
15
+ *
16
+ * Trust is keyed by `"<name>@<version>"` — any version bump (including a
17
+ * marketplace auto-update) invalidates the previous grant and re-prompts, the same
18
+ * supply-chain assumption package managers use for pinned versions.
19
+ */
20
+ import { $flag, getPluginTrustStorePath, isEnoent, logger } from "jeopi-utils";
21
+
22
+ export type PluginTrustDecision = "granted" | "denied";
23
+
24
+ /**
25
+ * Resolver for "do you trust this plugin?" consent.
26
+ *
27
+ * Return values mirror {@link import("../../tools/report-tool-issue").AutoQaConsentHandler}:
28
+ * - `true` — trusted; load for this run and persist.
29
+ * - `false` — untrusted; skip for this run and persist.
30
+ * - `null` — dismissed without a decision; skip this run only, re-prompt next time.
31
+ */
32
+ export type PluginTrustHandler = (plugin: { name: string; version: string }) => Promise<boolean | null>;
33
+
34
+ interface TrustStoreShape {
35
+ [trustKey: string]: PluginTrustDecision;
36
+ }
37
+
38
+ let trustHandler: PluginTrustHandler | null = null;
39
+ /** Process-global cache so a grant/denial in the parent applies to subagents immediately. */
40
+ const cachedDecisions = new Map<string, PluginTrustDecision>();
41
+ /** Single-flight in-flight prompts, keyed by trust key, so concurrent loaders don't stack popups. */
42
+ const inFlightPrompts = new Map<string, Promise<boolean>>();
43
+ let diskStoreCache: TrustStoreShape | null = null;
44
+
45
+ export function pluginTrustKey(plugin: { name: string; version: string }): string {
46
+ return `${plugin.name}@${plugin.version}`;
47
+ }
48
+
49
+ /** Register the interactive trust-prompt handler. Passing `null` clears it (teardown). */
50
+ export function setPluginTrustHandler(handler: PluginTrustHandler | null): void {
51
+ trustHandler = handler;
52
+ }
53
+
54
+ /** Test-only: reset all in-memory trust state. Never call from production code. */
55
+ export function __resetPluginTrustForTests(): void {
56
+ trustHandler = null;
57
+ cachedDecisions.clear();
58
+ inFlightPrompts.clear();
59
+ diskStoreCache = null;
60
+ }
61
+
62
+ async function readTrustStore(): Promise<TrustStoreShape> {
63
+ if (diskStoreCache) return diskStoreCache;
64
+ try {
65
+ diskStoreCache = (await Bun.file(getPluginTrustStorePath()).json()) as TrustStoreShape;
66
+ } catch (err) {
67
+ if (!isEnoent(err)) {
68
+ logger.debug("plugin trust store read failed", { error: String(err) });
69
+ }
70
+ diskStoreCache = {};
71
+ }
72
+ return diskStoreCache;
73
+ }
74
+
75
+ async function persistTrustDecision(trustKey: string, decision: PluginTrustDecision): Promise<void> {
76
+ const store = await readTrustStore();
77
+ store[trustKey] = decision;
78
+ diskStoreCache = store;
79
+ try {
80
+ await Bun.write(getPluginTrustStorePath(), JSON.stringify(store, null, "\t"));
81
+ } catch (err) {
82
+ logger.warn("plugin trust store write failed", { error: String(err) });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Resolve whether `plugin` is trusted to load. Never throws — handler errors
88
+ * degrade to "denied for this call" without caching, so a later invocation
89
+ * (e.g. after the user fixes their terminal) can re-prompt.
90
+ */
91
+ export async function resolvePluginTrust(plugin: { name: string; version: string }): Promise<boolean> {
92
+ // Escape hatch for headless/CI/scripted runs where no consent handler is ever
93
+ // registered (the default-deny branch below would otherwise silently disable
94
+ // every installed plugin). Explicit opt-in only — mirrors PI_AUTO_QA_PUSH.
95
+ if ($flag("PI_TRUST_ALL_PLUGINS", false)) return true;
96
+
97
+ const trustKey = pluginTrustKey(plugin);
98
+ const cached = cachedDecisions.get(trustKey);
99
+ if (cached !== undefined) return cached === "granted";
100
+
101
+ const store = await readTrustStore();
102
+ const persisted = store[trustKey];
103
+ if (persisted !== undefined) {
104
+ cachedDecisions.set(trustKey, persisted);
105
+ return persisted === "granted";
106
+ }
107
+
108
+ if (!trustHandler) return false;
109
+ const inFlight = inFlightPrompts.get(trustKey);
110
+ if (inFlight) return inFlight;
111
+
112
+ const handler = trustHandler;
113
+ const prompt = (async () => {
114
+ try {
115
+ const granted = await handler(plugin);
116
+ if (granted === null) {
117
+ // Dismissed (ESC/click-away): skip this call only, don't cache or persist.
118
+ return false;
119
+ }
120
+ const decision: PluginTrustDecision = granted ? "granted" : "denied";
121
+ cachedDecisions.set(trustKey, decision);
122
+ await persistTrustDecision(trustKey, decision);
123
+ return granted;
124
+ } catch (err) {
125
+ logger.warn("plugin trust handler threw", { error: String(err) });
126
+ return false;
127
+ } finally {
128
+ inFlightPrompts.delete(trustKey);
129
+ }
130
+ })();
131
+ inFlightPrompts.set(trustKey, prompt);
132
+ return prompt;
133
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Validation for the `evidence` argument the `complete` goal-tool op requires.
3
+ *
4
+ * Code-level counterpart to the prompt rule ("call `complete` only when the goal is
5
+ * actually done and verified" — `prompts/tools/goal.md`, `goal-continuation.md`).
6
+ * Before this, "call complete" was pure self-attestation: a model under budget
7
+ * pressure or scope-shrink temptation could close a goal with zero code-level
8
+ * friction. This does not (cannot) verify the claim is *true* — that still requires
9
+ * the model to have actually checked the repo state — but it forces the model to
10
+ * commit to a specific, non-trivial claim that a human reviewing the transcript can
11
+ * hold it to, and it rejects the exact rubber-stamp phrases the prompt already
12
+ * tells the model never to use as a reason to complete.
13
+ */
14
+
15
+ /** Below this length, evidence can't meaningfully describe what was verified. */
16
+ export const MIN_COMPLETION_EVIDENCE_LENGTH = 15;
17
+
18
+ /** Rubber-stamp phrases that carry no verification content, normalized (trimmed,
19
+ * lowercased, trailing punctuation stripped). Mirrors the intent of
20
+ * `advisor/emission-guard.ts`'s `SUPPRESSED_NORMALIZED_PHRASES`. */
21
+ const TRIVIAL_COMPLETION_EVIDENCE_PHRASES = new Set([
22
+ "done",
23
+ "complete",
24
+ "completed",
25
+ "finished",
26
+ "ok",
27
+ "okay",
28
+ "yes",
29
+ "verified",
30
+ "all done",
31
+ "n/a",
32
+ "na",
33
+ "none",
34
+ "good",
35
+ "looks good",
36
+ "lgtm",
37
+ "task complete",
38
+ "task done",
39
+ "goal complete",
40
+ "goal achieved",
41
+ "it works",
42
+ "it's done",
43
+ ]);
44
+
45
+ function normalizeCompletionEvidence(text: string): string {
46
+ return text
47
+ .trim()
48
+ .toLowerCase()
49
+ .replace(/[.!]+$/, "");
50
+ }
51
+
52
+ /**
53
+ * Validate that `evidence` is a substantive, non-generic completion claim.
54
+ * Returns the trimmed evidence on success; throws a plain `Error` describing
55
+ * what's missing otherwise (callers map this to their own error type).
56
+ */
57
+ export function assertSubstantiveCompletionEvidence(evidence: string | undefined): string {
58
+ const trimmed = evidence?.trim();
59
+ if (!trimmed) {
60
+ throw new Error(
61
+ "evidence is required when op=complete: state which deliverables you verified and how (tests run, output inspected, files diffed, etc.)",
62
+ );
63
+ }
64
+ if (
65
+ trimmed.length < MIN_COMPLETION_EVIDENCE_LENGTH ||
66
+ TRIVIAL_COMPLETION_EVIDENCE_PHRASES.has(normalizeCompletionEvidence(trimmed))
67
+ ) {
68
+ throw new Error(
69
+ "evidence is too generic to count as verification — describe the specific deliverables checked and how, not a status word",
70
+ );
71
+ }
72
+ return trimmed;
73
+ }
@@ -2,6 +2,7 @@ import { escapeXmlText, prompt, Snowflake } from "jeopi-utils";
2
2
  import goalBudgetLimitPrompt from "../prompts/goals/goal-budget-limit.md" with { type: "text" };
3
3
  import goalContinuationPrompt from "../prompts/goals/goal-continuation.md" with { type: "text" };
4
4
  import goalModeActivePrompt from "../prompts/goals/goal-mode-active.md" with { type: "text" };
5
+ import { assertSubstantiveCompletionEvidence } from "./completion-evidence";
5
6
  import type { Goal, GoalBudgetSteering, GoalModeState, GoalRuntimeEvent, GoalTokenUsage } from "./state";
6
7
 
7
8
  export interface GoalRuntimeHost {
@@ -470,7 +471,10 @@ export class GoalRuntime {
470
471
  });
471
472
  }
472
473
 
473
- async completeGoalFromTool(): Promise<Goal> {
474
+ async completeGoalFromTool(evidence: string): Promise<Goal> {
475
+ // Validated here too (not just in GoalTool), so any direct GoalRuntime caller
476
+ // (SDK embedders bypassing the tool schema) gets the same guardrail.
477
+ const verifiedEvidence = assertSubstantiveCompletionEvidence(evidence);
474
478
  return await this.#withAccounting(async () => {
475
479
  await this.#flushUsageLocked("suppressed");
476
480
  const state = this.#getStateClone();
@@ -485,6 +489,7 @@ export class GoalRuntime {
485
489
  }
486
490
  state.enabled = false;
487
491
  state.goal.status = "complete";
492
+ state.goal.completionEvidence = verifiedEvidence;
488
493
  state.goal.updatedAt = this.#now();
489
494
  state.mode = "exiting";
490
495
  state.reason = "completed";
@@ -11,6 +11,9 @@ export interface Goal {
11
11
  timeUsedSeconds: number;
12
12
  createdAt: number;
13
13
  updatedAt: number;
14
+ /** Set only when status transitions to "complete"; the caller's verification claim
15
+ * (see `goals/completion-evidence.ts`). Absent for goals that never completed. */
16
+ completionEvidence?: string;
14
17
  }
15
18
 
16
19
  export interface GoalModeState {