@alphacoder-v0/pi-loops 0.17.5 → 0.18.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.
package/src/pi-loops.ts CHANGED
@@ -33,9 +33,11 @@ import { capRedacted, previewRedacted, redact } from "./redact.ts";
33
33
  import { shouldEmitSnapshot, snapshotFingerprint } from "./snapshot.ts";
34
34
  import { type ShareMessage, renderShare, shareSummary } from "./share.ts";
35
35
  import { installLauncherWithConfirm } from "./cli.ts";
36
+ import { execFile } from "node:child_process";
36
37
  import { createHash } from "node:crypto";
37
38
  import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
38
39
  import { applyJobEdit } from "./job-edit.ts";
40
+ import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, purgeInstall, readRecord, requireRecipeName, resolveRecipeRef, updateFiles } from "./recipe.ts";
39
41
  import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
40
42
  import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
41
43
  import { parentRuntimeFlags, type SubagentRequest } from "./runner.ts";
@@ -1840,6 +1842,314 @@ export default function piLoops(pi: ExtensionAPI) {
1840
1842
  },
1841
1843
  });
1842
1844
 
1845
+ /* ------------------------------------------------------------------ /recipe */
1846
+
1847
+ // `add <ref>` rather than `add <name|path>`: the menu is read back by a test that splits on `|`.
1848
+ const RECIPE_USAGE = "[list|show <ref>|add <ref> [--level <l>]|update <name>|remove <name> [--purge]|help]";
1849
+
1850
+ const RECIPE_HELP = [
1851
+ "/recipe the packaged recipes, and which of them are installed here",
1852
+ "/recipe show <name|path> what one would install: playbooks, jobs, levels, setup script",
1853
+ "/recipe add <name|path> install it in this project: copies the playbooks to .agents/skills/<name>/,",
1854
+ " runs the setup script after showing it, creates the jobs. --level report|propose|act skips the question",
1855
+ "/recipe update <name> merge the packaged playbooks into the installed copies; your edits are kept",
1856
+ "/recipe remove <name> remove its jobs; --purge also deletes the installed files and the loops' notes",
1857
+ "/recipe help this text",
1858
+ "",
1859
+ "A recipe is a directory with a recipe.toml (docs/recipes.md). The copies live in .agents/skills/<name>/,",
1860
+ "kept out of the repository through .git/info/exclude, and a run reads them fresh every time.",
1861
+ ];
1862
+
1863
+ const LEVEL_TEXT: Record<AutonomyLevel, string> = {
1864
+ report: "read, and file findings; nothing written outside the inbox",
1865
+ propose: "also write to the tracker and open draft pull requests; never a terminal state",
1866
+ act: "also promote, close and merge — the terminal states",
1867
+ };
1868
+
1869
+
1870
+ /** The prompt that hands the tracker description to the session; the text lives beside the recipes. */
1871
+ function trackerSetupPrompt(recipe: Recipe, project: string): string {
1872
+ const text = fs.readFileSync(path.join(packagedRecipesDir(), "_tracker-setup.md"), "utf8");
1873
+ // Function replacements: a path with `$&` in it must land as typed.
1874
+ return text.replace(/\{recipe\}/g, () => recipe.manifest.name).replace(/\{project\}/g, () => project).replace(/\{templates\}/g, () => path.join(packagedRecipesDir(), "_tracker"));
1875
+ }
1876
+
1877
+ /** Paths, not contents: the files are on disk and the model has tools; a prompt is not a place to keep a file. */
1878
+ function mergeConflictPrompt(name: string, dir: string, conflicts: UpdateResult["conflicts"]): string {
1879
+ const blocks = conflicts.map((c) => [`### ${c.file}`, `- installed copy (yours, unchanged by the update): ${path.join(dir, c.file)}`, `- untouched copy from install time (the merge base): ${c.basePath}`, `- newly packaged version: ${c.packagedPath}`, `- git's three-way merge with conflict markers: ${c.mergePath}`].join("\n")).join("\n\n");
1880
+ return [
1881
+ `\`/recipe update ${name}\` merged the newly packaged playbooks into the installed copies in ${dir}. The files below have edits on both sides that overlap, so the installed copies were NOT changed.`,
1882
+ "",
1883
+ "Work through them with me: read the merge file, say what changed on each side, propose the resolved text; when I agree, write the resolved file over the installed copy (no conflict markers may remain — a loop reads that file and follows it), copy the packaged version over the untouched copy (that is what the next update merges against), and delete the merge file.",
1884
+ "",
1885
+ blocks,
1886
+ ].join("\n");
1887
+ }
1888
+
1889
+ function runSetupScript(script: string, cwd: string): Promise<{ code: number; output: string }> {
1890
+ return new Promise((resolve) => {
1891
+ execFile("sh", [script], { cwd, timeout: 120_000, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
1892
+ const code = err && typeof (err as { code?: unknown }).code === "number" ? ((err as { code: number }).code as number) : err ? 1 : 0;
1893
+ resolve({ code, output: `${stdout}${stderr ? `\n${stderr}` : ""}`.trim() });
1894
+ });
1895
+ });
1896
+ }
1897
+
1898
+ /**
1899
+ * `/recipe add`, as a function, because it runs twice when a tracker description has to be
1900
+ * written first: once from the command, and once more on its own when the file the agent was
1901
+ * asked for is there — a person should not have to type the command a second time.
1902
+ */
1903
+ let pendingRecipeAdd: { words: string[]; project: string } | undefined;
1904
+ async function recipeAdd(ctx: ExtensionContext, words: string[], resumed = false): Promise<void> {
1905
+ const project = session.cwd;
1906
+ const { ref, level: levelFlag } = parseAddWords(words);
1907
+ if (!ref) {
1908
+ ctx.ui.notify(`usage: /recipe add <name|path> [--level ${AUTONOMY_LEVELS.join("|")}]`, "warning");
1909
+ return;
1910
+ }
1911
+ const resolved = resolveRecipeRef(ref, { cwd: project });
1912
+ const recipe = loadRecipe(resolved.dir);
1913
+ const m = recipe.manifest;
1914
+ // The one step a template cannot do: describe this project's tracker. Handed to the
1915
+ // session the way /inbox claim hands over a finding; the wizard resumes when the file exists.
1916
+ if (m.needsTracker && !fs.existsSync(path.join(project, TRACKER_FILE))) {
1917
+ if (resumed) {
1918
+ ctx.ui.notify(`${m.name} still has no ${TRACKER_FILE} to read; /recipe add ${ref} when it exists`, "warning");
1919
+ return;
1920
+ }
1921
+ pendingRecipeAdd = { words, project };
1922
+ pi.sendUserMessage(trackerSetupPrompt(recipe, project), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
1923
+ ctx.ui.notify(`${m.name} reads ${TRACKER_FILE}, and this project has none yet — the agent will write it with you now, and the install continues here as soon as it exists`, "info");
1924
+ return;
1925
+ }
1926
+ let level: AutonomyLevel | undefined;
1927
+ if (levelFlag) {
1928
+ if (!m.levels.includes(levelFlag as AutonomyLevel)) throw new Error(`${m.name} supports ${m.levels.join(", ")}, not "${levelFlag}"`);
1929
+ level = levelFlag as AutonomyLevel;
1930
+ } else if (m.levels.length === 1) level = m.levels[0];
1931
+ else {
1932
+ const choice = await ctx.ui.select(`Autonomy level for ${m.name} (the lowest is the default)`, m.levels.map((l) => `${l} — ${LEVEL_TEXT[l]}`));
1933
+ level = choice?.split(" ")[0] as AutonomyLevel | undefined;
1934
+ }
1935
+ if (!level) {
1936
+ ctx.ui.notify("not installed", "info");
1937
+ return;
1938
+ }
1939
+ const existing = scheduler.store.load();
1940
+ const clash = m.jobs.map((j) => existing.find((e) => e.name === j.name)).filter((e): e is LoopJob => !!e);
1941
+ if (clash.length) {
1942
+ show(ctx, `not installed: ${clash.length} job name(s) already in use`, [
1943
+ ...clash.map((e) => ` "${e.name}" — ${e.recipe ? `from recipe ${e.recipe}` : "created with /cron add"}, in ${homeRel(e.cwd)}`),
1944
+ "",
1945
+ ` ${clash.some((e) => e.recipe === m.name) ? `/recipe remove ${m.name}` : `/cron remove <name>`} first, then /recipe add ${ref} again`,
1946
+ ]);
1947
+ return;
1948
+ }
1949
+ const plan = planInstall(recipe, project, level);
1950
+ let overwrite = false;
1951
+ if (plan.changed.length) {
1952
+ overwrite = await ctx.ui.confirm(`Overwrite ${plan.changed.length} edited file(s)?`, [...plan.changed.map((f) => ` ${f}`), "", "These differ from what would be written. Yes replaces them with the packaged version; No keeps your copies (the record is still written, and /recipe update merges later)."].join("\n"));
1953
+ }
1954
+ const setupText = m.setup ? fs.readFileSync(path.join(recipe.dir, m.setup), "utf8") : undefined;
1955
+ // The confirmation is the only gate before `sh <script>` runs with this session's
1956
+ // environment. So the script is shown whole and as written — redaction here would
1957
+ // hide exactly the `password=$(…)` a reader needs to see — or not run at all.
1958
+ if (setupText !== undefined && setupText.length > MAX_SETUP_SHOWN) {
1959
+ show(ctx, `not installed: ${m.setup} is ${setupText.length} characters, more than a confirmation can show`, [` read ${path.join(recipe.dir, m.setup ?? "")} yourself, run it by hand in ${homeRel(project)}, then install a copy of the recipe without \`setup\` in its recipe.toml`]);
1960
+ return;
1961
+ }
1962
+ const fromPath = !isRecipeName(resolved.source);
1963
+ // A dialog clips what does not fit the terminal, so the script goes into the transcript
1964
+ // first — whole, scrollable, unredacted — and the dialog points at it.
1965
+ if (setupText !== undefined) show(ctx, `setup script ${m.setup} of recipe ${m.name} — runs once in ${homeRel(project)} with this session's environment, if you say yes below`, setupText.split("\n").map((l) => ` ${l}`));
1966
+ const body = [
1967
+ ...(fromPath ? [`NOT shipped with pi-loops: ${homeRel(recipe.dir)}. Its playbooks will be followed by unattended runs, with this session's tools and credentials — read them before saying yes:`, ...plan.files.map((f) => ` ${path.join(recipe.dir, f)}`), ""] : []),
1968
+ `Files → ${homeRel(plan.targetDir)}/ (listed in .git/info/exclude, so nothing enters the repository):`,
1969
+ ...plan.files.map((f) => ` ${f}${plan.changed.includes(f) ? (overwrite ? " (overwritten)" : " (kept as edited)") : ""}`),
1970
+ ...(setupText !== undefined ? ["", `Setup script ${m.setup} (${setupText.split("\n").length} lines, printed above and at ${path.join(recipe.dir, m.setup ?? "")}) runs once with this session's environment.`] : []),
1971
+ "",
1972
+ "Jobs:",
1973
+ ...plan.addLines.map((l) => ` /cron add ${l}`),
1974
+ ...(m.budgetHintUsd ? ["", `Budget hint: about $${m.budgetHintUsd}/day — cap it with [limits] daily_budget_usd in config.toml`] : []),
1975
+ ];
1976
+ const ok = await ctx.ui.confirm(`Install recipe ${m.name} at level "${level}"?`, body.join("\n"));
1977
+ if (!ok) {
1978
+ ctx.ui.notify("not installed", "info");
1979
+ return;
1980
+ }
1981
+ const lines: string[] = [];
1982
+ const record = installFiles(recipe, project, level, { overwrite, source: resolved.source });
1983
+ lines.push(` ${record.files.length} file(s) → ${homeRel(plan.targetDir)}/`);
1984
+ const excluded = ensureExcluded(project, path.join(INSTALL_ROOT, m.name));
1985
+ // The tracker description names an account and a workflow; it is the project's to keep, not
1986
+ // the repository's to publish — so it stays out of history the same way the playbooks do.
1987
+ const trackerExcluded = m.needsTracker ? ensureExcluded(project, TRACKER_FILE, "file") : undefined;
1988
+ lines.push(excluded === "no-git" ? " not a git repository: nothing to exclude" : ` .git/info/exclude: ${excluded === "added" ? "added" : "already listed"}${trackerExcluded ? `, ${TRACKER_FILE} ${trackerExcluded === "added" ? "added" : "already listed"}` : ""}`);
1989
+ if (m.setup) {
1990
+ const r = await runSetupScript(path.join(plan.targetDir, m.setup), project);
1991
+ const out = capRedacted(r.output, 2000).split("\n").map((l) => ` ${l}`);
1992
+ if (r.code !== 0) {
1993
+ show(ctx, `setup script ${m.setup} failed (exit ${r.code}); no jobs were created`, [...lines, ` ${m.setup}:`, ...out, "", ` the files are in place — fix what it needs and run /recipe add ${ref} again`]);
1994
+ return;
1995
+ }
1996
+ lines.push(` ${m.setup}: ok`, ...out);
1997
+ }
1998
+ for (const line of plan.addLines) {
1999
+ const parsed = parseAddArgs(line);
2000
+ const job = await createJob({ schedule: parsed.schedule, prompt: parsed.prompt, stateful: parsed.stateful, name: parsed.name, model: parsed.model, thinking: parsed.thinking, tools: parsed.tools, timeoutMs: parsed.timeoutMs, catchUp: parsed.catchUp, verify: parsed.verify, checkerModel: parsed.checkerModel, recipe: m.name });
2001
+ cronControlAudit("add", "slash", undefined, job);
2002
+ const next = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt) }, Date.now());
2003
+ lines.push(` job ${job.name}: ${formatSchedule(job.schedule)}${next ? ` (next run ${formatLocal(next)})` : ""}`);
2004
+ }
2005
+ lines.push("", ` edit the playbooks in ${path.join(INSTALL_ROOT, m.name)}/ — a run reads them fresh every time; /cron run <job> tries one now`);
2006
+ show(ctx, `installed recipe ${m.name} (${level})`, lines);
2007
+ refreshBadge();
2008
+ return;
2009
+
2010
+ }
2011
+
2012
+ pi.registerCommand("recipe", {
2013
+ description: "Install a packaged way of running this project on loops — /recipe help",
2014
+ getArgumentCompletions: (prefix) => {
2015
+ const subs = ["list", "show", "add", "update", "remove", "help"];
2016
+ const items = subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
2017
+ return items.length ? items : null;
2018
+ },
2019
+ handler: async (args, ctx: ExtensionCommandContext) => {
2020
+ lastCtx = ctx;
2021
+ const { sub, rest } = splitCommand(args);
2022
+ const words = tokenize(rest).map((t) => t.value);
2023
+ const project = session.cwd;
2024
+ try {
2025
+ switch (sub) {
2026
+ case "":
2027
+ case "list":
2028
+ case "ls": {
2029
+ const packaged = listRecipes();
2030
+ const installed = new Map(installedRecipes(project).map((r) => [r.recipe, r]));
2031
+ const jobs = scheduler.store.load().filter((j) => j.recipe && sameProject(j.cwd, project));
2032
+ const lines = packaged.map((r) => {
2033
+ const rec = installed.get(r.manifest.name);
2034
+ const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
2035
+ return ` ${r.manifest.name.padEnd(17)} ${(rec ? `installed: ${rec.level}, ${n} job(s)` : "—").padEnd(28)} ${r.manifest.summary}`;
2036
+ });
2037
+ for (const rec of installed.values()) {
2038
+ if (packaged.some((r) => r.manifest.name === rec.recipe)) continue;
2039
+ lines.push(` ${rec.recipe.padEnd(17)} installed from ${homeRel(rec.source)} (${rec.level})`);
2040
+ }
2041
+ show(ctx, `Recipes (${homeRel(project)})`, [...(lines.length ? lines : [" none packaged, none installed"]), "", " /recipe show <name> · /recipe add <name> · /recipe help"]);
2042
+ return;
2043
+ }
2044
+ case "show": {
2045
+ const ref = words[0];
2046
+ if (!ref) {
2047
+ ctx.ui.notify("usage: /recipe show <name|path>", "warning");
2048
+ return;
2049
+ }
2050
+ const recipe = loadRecipe(resolveRecipeRef(ref, { cwd: project }).dir);
2051
+ const m = recipe.manifest;
2052
+ const rel = path.join(INSTALL_ROOT, m.name);
2053
+ const record = readRecord(installDirFor(project, m.name));
2054
+ const lines = [
2055
+ ` ${m.summary}`,
2056
+ ` levels: ${m.levels.map((l) => `${l} (${LEVEL_TEXT[l]})`).join("; ")}`,
2057
+ ` tracker: ${m.needsTracker ? `needs ${TRACKER_FILE} — ${fs.existsSync(path.join(project, TRACKER_FILE)) ? "present" : "missing here; /recipe add writes it with you"}` : "not needed"}`,
2058
+ ` files → ${rel}/: ${playbookFiles(m).join(", ")}`,
2059
+ ...(m.setup ? [` setup script: ${m.setup} (shown before it runs, once per project)`] : []),
2060
+ ...(m.budgetHintUsd ? [` budget hint: about $${m.budgetHintUsd}/day at the default schedules — [limits] daily_budget_usd in config.toml`] : []),
2061
+ " jobs:",
2062
+ ...m.jobs.map((j) => ` /cron add ${addLineFor(j, rel)}`),
2063
+ ...(record ? [` installed here: ${record.level}, ${record.installedAt}, pi-loops ${record.version}`] : []),
2064
+ ];
2065
+ show(ctx, `recipe ${m.name} (${homeRel(recipe.dir)})`, lines);
2066
+ return;
2067
+ }
2068
+ case "add": {
2069
+ pendingRecipeAdd = undefined;
2070
+ await recipeAdd(ctx, words);
2071
+ return;
2072
+ }
2073
+ case "update": {
2074
+ const name = words[0];
2075
+ if (!name) {
2076
+ ctx.ui.notify("usage: /recipe update <name>", "warning");
2077
+ return;
2078
+ }
2079
+ requireRecipeName(name);
2080
+ const dir = installDirFor(project, name);
2081
+ const record = readRecord(dir);
2082
+ if (!record) {
2083
+ ctx.ui.notify(`no recipe "${name}" is installed in ${homeRel(project)} (/recipe list)`, "warning");
2084
+ return;
2085
+ }
2086
+ const recipe = loadRecipe(path.isAbsolute(record.source) ? record.source : resolveRecipeRef(record.source, { cwd: project }).dir);
2087
+ const result = updateFiles(recipe, project, record.level);
2088
+ const lines = [
2089
+ ...(result.updated.length ? [` updated: ${result.updated.join(", ")}`] : []),
2090
+ ...(result.unchanged.length ? [` unchanged: ${result.unchanged.join(", ")}`] : []),
2091
+ ...(result.noBase.length ? [` left alone (no .orig to merge against): ${result.noBase.join(", ")}`] : []),
2092
+ ...(result.conflicts.length ? [` conflicts, not written: ${result.conflicts.map((c) => c.file).join(", ")} — handed to the agent to resolve with you`] : []),
2093
+ ];
2094
+ show(ctx, `recipe ${name}: merged pi-loops ${PI_LOOPS_VERSION} playbooks into ${homeRel(dir)}/`, lines);
2095
+ if (result.conflicts.length) pi.sendUserMessage(mergeConflictPrompt(name, dir, result.conflicts), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
2096
+ return;
2097
+ }
2098
+ case "remove":
2099
+ case "rm": {
2100
+ pendingRecipeAdd = undefined;
2101
+ const purge = words.includes("--purge");
2102
+ const name = words.find((w) => !w.startsWith("--"));
2103
+ if (!name) {
2104
+ ctx.ui.notify("usage: /recipe remove <name> [--purge]", "warning");
2105
+ return;
2106
+ }
2107
+ requireRecipeName(name);
2108
+ const dir = installDirFor(project, name);
2109
+ const jobs = scheduler.store.load().filter((j) => j.recipe === name && sameProject(j.cwd, project));
2110
+ const record = readRecord(dir);
2111
+ if (!jobs.length && !record) {
2112
+ ctx.ui.notify(`no recipe "${name}" is installed in ${homeRel(project)} (/recipe list)`, "warning");
2113
+ return;
2114
+ }
2115
+ const ok = await ctx.ui.confirm(`Remove recipe ${name}?`, [
2116
+ jobs.length ? `${jobs.length} job(s): ${jobs.map((j) => j.name ?? j.id).join(", ")}` : "no jobs of its own here",
2117
+ purge ? `and delete what the install wrote in ${homeRel(dir)}/ (${record ? `${record.files.length} file(s), their untouched copies, the record` : "no record: only the directory if it is empty"}) and the loops' notes` : `the files in ${homeRel(dir)}/ and the loops' notes are kept (--purge deletes both)`,
2118
+ ].join("\n"));
2119
+ if (!ok) {
2120
+ ctx.ui.notify("not removed", "info");
2121
+ return;
2122
+ }
2123
+ for (const job of jobs) {
2124
+ await scheduler.store.remove(job.id, { purge });
2125
+ cronControlAudit("remove", "slash", job, undefined);
2126
+ }
2127
+ if (purge) {
2128
+ if (record) purgeInstall(dir, record, listRecipes().find((r) => r.manifest.name === name)?.manifest.setup);
2129
+ else {
2130
+ try {
2131
+ fs.rmdirSync(dir);
2132
+ } catch {
2133
+ // not empty or not there: nothing of ours to take
2134
+ }
2135
+ }
2136
+ }
2137
+ refreshBadge();
2138
+ ctx.ui.notify(`removed recipe ${name}: ${jobs.length} job(s)${purge ? ", its files and notes" : `; files kept in ${homeRel(dir)}/`}`, "info");
2139
+ return;
2140
+ }
2141
+ case "help":
2142
+ show(ctx, "/recipe", RECIPE_HELP.map((l) => ` ${l}`));
2143
+ return;
2144
+ default:
2145
+ ctx.ui.notify(`unknown /recipe command: ${sub}. usage: /recipe ${RECIPE_USAGE}`, "warning");
2146
+ }
2147
+ } catch (e) {
2148
+ ctx.ui.notify(`/recipe ${sub}: ${(e as Error).message}`, "error");
2149
+ }
2150
+ },
2151
+ });
2152
+
1843
2153
  // Not `share`: pi has a built-in `/share` of its own, and an extension command with a built-in's
1844
2154
  // name is dropped from autocomplete and shadowed at the prompt. The name follows the two
1845
2155
  // commands next to it (`/session-export`, `/session-import`), which are about the same object.
@@ -2210,6 +2520,17 @@ export default function piLoops(pi: ExtensionAPI) {
2210
2520
  // The goal is evaluated at the end of a turn; pi's hook for that is `agent_settled` — the point where
2211
2521
  // no automatic retry, compaction or queued continuation will run, so a decision here is final.
2212
2522
  pi.on("agent_settled", async (_event, ctx) => {
2523
+ // The tracker description `/recipe add` was waiting for: written during this turn, so the
2524
+ // wizard picks up where it stopped — in the same project, with the same words.
2525
+ if (pendingRecipeAdd && sameProject(pendingRecipeAdd.project, session.cwd) && fs.existsSync(path.join(pendingRecipeAdd.project, TRACKER_FILE))) {
2526
+ const { words } = pendingRecipeAdd;
2527
+ pendingRecipeAdd = undefined;
2528
+ try {
2529
+ await recipeAdd(ctx, words, true);
2530
+ } catch (e) {
2531
+ ctx.ui.notify(`/recipe add: ${(e as Error).message}`, "error");
2532
+ }
2533
+ }
2213
2534
  lastCtx = ctx;
2214
2535
  try {
2215
2536
  await evaluateGoal(ctx);