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.
- package/CHANGELOG.md +31 -0
- package/dist/cli.js +2565 -2524
- package/dist/types/advisor/runtime.d.ts +9 -1
- package/dist/types/cli/update-cli.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/extensibility/plugins/loader.d.ts +4 -4
- package/dist/types/extensibility/plugins/trust.d.ts +30 -0
- package/dist/types/goals/completion-evidence.d.ts +21 -0
- package/dist/types/goals/runtime.d.ts +1 -1
- package/dist/types/goals/state.d.ts +3 -0
- package/dist/types/goals/tools/goal-tool.d.ts +2 -0
- package/dist/types/main.d.ts +20 -0
- package/dist/types/mnemopi/state.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/dist/types/task/critic-gate.d.ts +34 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/index.d.ts +6 -1
- package/dist/types/tools/subagent.d.ts +70 -0
- package/dist/types/tools/subagent.test.d.ts +1 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +173 -0
- package/src/advisor/runtime.ts +66 -2
- package/src/cli/update-cli.ts +144 -0
- package/src/config/settings-schema.ts +14 -2
- package/src/extensibility/plugins/loader.ts +35 -9
- package/src/extensibility/plugins/trust.ts +133 -0
- package/src/goals/completion-evidence.ts +73 -0
- package/src/goals/runtime.ts +6 -1
- package/src/goals/state.ts +3 -0
- package/src/goals/tools/goal-tool.ts +16 -1
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/main.ts +56 -10
- package/src/memories/index.ts +21 -1
- package/src/mnemopi/state.ts +10 -2
- package/src/modes/interactive-mode.ts +21 -0
- package/src/prompts/memories/read-path.md +3 -1
- package/src/prompts/system/safety-kernel.md +17 -0
- package/src/prompts/system/tool-call-cycle-redirect.md +5 -0
- package/src/prompts/tools/goal.md +1 -1
- package/src/prompts/tools/subagent.md +29 -0
- package/src/session/agent-session.ts +68 -19
- package/src/slash-commands/builtin-registry.ts +18 -0
- package/src/system-prompt.ts +14 -2
- package/src/task/critic-gate.ts +46 -0
- package/src/task/index.ts +63 -2
- package/src/tools/builtin-names.ts +1 -0
- package/src/tools/index.ts +9 -1
- package/src/tools/subagent.test.ts +285 -0
- package/src/tools/subagent.ts +582 -0
package/src/advisor/runtime.ts
CHANGED
|
@@ -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:
|
|
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");
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/goals/runtime.ts
CHANGED
|
@@ -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";
|
package/src/goals/state.ts
CHANGED
|
@@ -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 {
|