From eaff5e4d62f8ee55528370188ddbb1214cd68db7 Mon Sep 17 00:00:00 2001 From: Automaker Date: Fri, 22 May 2026 17:10:37 -0700 Subject: [PATCH 1/2] feat(cli): add /goal and /loop commands for long-horizon tasks /goal sets a verifiable completion condition; after each turn a small evaluator call decides whether the condition holds and either marks the goal achieved or injects the evaluator's reason as the next-turn directive. Wires into client.ts at the same continuation site as the existing Stop hook and CompletionChecker paths, so feedback drives a new turn via the same sendMessageStream recursion the rest of the codebase uses. /loop schedules a recurring prompt via the existing CronScheduler so it inherits jitter, expiry, and the idle-drain submit path already wired in useGeminiStream. Supports both leading-token (5m check deploy) and trailing "every N unit" parsing, a 10m default, list/clear/stop, and single-job cancellation by 8-char id. Replaces the bundled loop skill that used to model-route /loop input through cron_create / cron_list / cron_delete tools; the tools stay for natural-language scheduling. New modules in core: goal/ (manager, evaluator) and loop/ (intervalParser, intervalToCron). Docs added at docs/guides/goal.md and the existing docs/guides/scheduled-tasks.md picks up the new management syntax. 815 tests pass across the directories touched. Co-Authored-By: Claude Opus 4.7 --- docs/guides/_meta.ts | 1 + docs/guides/goal.md | 76 ++++++ docs/guides/scheduled-tasks.md | 6 +- .../cli/src/services/BuiltinCommandLoader.ts | 4 + .../cli/src/ui/commands/goalCommand.test.ts | 158 ++++++++++++ packages/cli/src/ui/commands/goalCommand.ts | 125 ++++++++++ .../cli/src/ui/commands/loopCommand.test.ts | 230 ++++++++++++++++++ packages/cli/src/ui/commands/loopCommand.ts | 217 +++++++++++++++++ packages/core/src/config/config.ts | 12 + packages/core/src/core/client.ts | 70 ++++++ packages/core/src/goal/GoalManager.test.ts | 155 ++++++++++++ packages/core/src/goal/GoalManager.ts | 125 ++++++++++ packages/core/src/goal/goalEvaluator.test.ts | 128 ++++++++++ packages/core/src/goal/goalEvaluator.ts | 163 +++++++++++++ packages/core/src/goal/index.ts | 16 ++ packages/core/src/goal/types.ts | 57 +++++ packages/core/src/index.ts | 5 + packages/core/src/loop/index.ts | 22 ++ packages/core/src/loop/intervalParser.test.ts | 67 +++++ packages/core/src/loop/intervalParser.ts | 83 +++++++ packages/core/src/loop/intervalToCron.test.ts | 73 ++++++ packages/core/src/loop/intervalToCron.ts | 97 ++++++++ packages/core/src/loop/types.ts | 24 ++ .../core/src/skills/bundled/loop/SKILL.md | 61 ----- 24 files changed, 1912 insertions(+), 63 deletions(-) create mode 100644 docs/guides/goal.md create mode 100644 packages/cli/src/ui/commands/goalCommand.test.ts create mode 100644 packages/cli/src/ui/commands/goalCommand.ts create mode 100644 packages/cli/src/ui/commands/loopCommand.test.ts create mode 100644 packages/cli/src/ui/commands/loopCommand.ts create mode 100644 packages/core/src/goal/GoalManager.test.ts create mode 100644 packages/core/src/goal/GoalManager.ts create mode 100644 packages/core/src/goal/goalEvaluator.test.ts create mode 100644 packages/core/src/goal/goalEvaluator.ts create mode 100644 packages/core/src/goal/index.ts create mode 100644 packages/core/src/goal/types.ts create mode 100644 packages/core/src/loop/index.ts create mode 100644 packages/core/src/loop/intervalParser.test.ts create mode 100644 packages/core/src/loop/intervalParser.ts create mode 100644 packages/core/src/loop/intervalToCron.test.ts create mode 100644 packages/core/src/loop/intervalToCron.ts create mode 100644 packages/core/src/loop/types.ts delete mode 100644 packages/core/src/skills/bundled/loop/SKILL.md diff --git a/docs/guides/_meta.ts b/docs/guides/_meta.ts index 8258bd99f..3baae01b1 100644 --- a/docs/guides/_meta.ts +++ b/docs/guides/_meta.ts @@ -14,6 +14,7 @@ export default { 'use-sandbox': 'Sandboxing', 'run-headless': 'Run Headless (Non-Interactive)', 'scheduled-tasks': 'Schedule Prompts', + goal: 'Work Toward a Goal', // --- Workflow --- 'approval-mode': 'Approval Mode', 'manage-memory': 'Manage Memory', diff --git a/docs/guides/goal.md b/docs/guides/goal.md new file mode 100644 index 000000000..e2c8437b5 --- /dev/null +++ b/docs/guides/goal.md @@ -0,0 +1,76 @@ +# Keep proto working toward a goal + +Set a completion condition with `/goal` and proto keeps working across turns until the condition is met. After every turn a small fast model checks the transcript against your condition; if it isn't satisfied yet, proto starts another turn instead of returning control. The goal clears automatically once the condition is met. + +Use a goal for substantial work with a verifiable end state: + +- Migrating a module to a new API until every call site compiles and tests pass +- Implementing a design doc until all acceptance criteria hold +- Splitting a large file into focused modules until each is under a size budget +- Working through a labeled issue backlog until the queue is empty + +## Set a goal + +Run `/goal` followed by the condition you want satisfied. + +``` +/goal all tests in test/auth pass and the lint step is clean +``` + +Setting a goal starts a turn immediately, with the condition itself as the directive — you do not need to send a separate prompt. While the goal is active, the evaluator's most recent reason is shown on `/goal` so you can see what proto is working toward. + +> [!note] +> One goal can be active per session. Running `/goal ` replaces the previous one. + +The condition can be up to 4,000 characters. To bound how long a goal runs, include a clause like `or stop after 20 turns` directly in the condition. + +## Write an effective condition + +The evaluator only sees what proto has surfaced in the transcript — tool calls and the final assistant message. Write the condition so that proto's own output can demonstrate it. + +A good condition usually has: + +- **One measurable end state**: a test result, a build exit code, a file count, an empty queue. +- **A stated check**: how proto should prove it, such as `npm test exits 0` or `git status is clean`. +- **Constraints that matter**: anything that must not change on the way there, such as `no other test file is modified`. + +"All tests in `test/auth` pass" works because proto runs the tests and the result lands in the transcript for the evaluator to read. "The code is good" does not, because nothing in the transcript can prove it. + +## Check status + +Run `/goal` with no arguments to inspect the current state. + +``` +/goal +``` + +If a goal is active, the status shows the condition, how long it has been running, how many turns have been evaluated, the tokens spent on evaluation so far, and the evaluator's most recent reason. If no goal is active but one was achieved earlier in the session, the status shows the achieved condition along with how long it took. + +## Clear a goal + +Run `/goal clear` to remove an active goal before its condition is met. Any of `stop`, `off`, `reset`, `none`, and `cancel` are accepted as aliases for `clear`. Starting a new conversation with `/clear` also removes any active goal. + +``` +/goal clear +``` + +## How evaluation works + +Each time the main agent finishes a turn, the condition and the conversation so far are sent to your configured content generator for a one-shot evaluator call. The evaluator returns a yes-or-no decision and a short reason. A "no" tells proto to keep working and includes the reason as guidance for the next turn; a "yes" clears the goal and records the achieved entry on `/goal`. + +The evaluator does not call tools, so it can only judge what proto has already surfaced in the conversation. If the evaluator can't tell from the transcript, treat it as "no" and ask for the missing evidence. + +## How `/goal` differs from `/loop` + +| Trigger for next turn | `/goal` | `/loop` | +| --------------------- | ------------------------------------------- | ------------------------------------------------ | +| When it fires | Previous turn ends | A time interval elapses | +| When it stops | The evaluator confirms the condition is met | You cancel it, or proto decides the work is done | +| Best for | Verifiable end states | Polling / babysitting on a cadence | + +See [Schedule prompts](./scheduled-tasks.md) for `/loop`. + +## See also + +- [Schedule prompts](./scheduled-tasks.md) — re-run a prompt on a time interval +- [Use hooks](./use-hooks.md) — write your own Stop hook when you need custom evaluation logic diff --git a/docs/guides/scheduled-tasks.md b/docs/guides/scheduled-tasks.md index 8a0919aff..129fb342c 100644 --- a/docs/guides/scheduled-tasks.md +++ b/docs/guides/scheduled-tasks.md @@ -37,8 +37,10 @@ Each time the job fires, proto runs `/review-pr 1234` as if you had typed it. ### Manage loops ``` -/loop list # list all scheduled jobs -/loop clear # cancel all jobs +/loop # list active jobs (same as /loop list) +/loop list # explicit list +/loop # cancel a single job by its 8-character id +/loop stop # cancel every active job (aliases: clear, off, cancel) ``` ## Set a one-time reminder diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 45ccf706f..6cb8b6d12 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -23,11 +23,13 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { exportCommand } from '../ui/commands/exportCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; +import { goalCommand } from '../ui/commands/goalCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { languageCommand } from '../ui/commands/languageCommand.js'; +import { loopCommand } from '../ui/commands/loopCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; @@ -86,11 +88,13 @@ export class BuiltinCommandLoader implements ICommandLoader { editorCommand, exportCommand, extensionsCommand, + goalCommand, helpCommand, hooksCommand, await ideCommand(), initCommand, languageCommand, + loopCommand, mcpCommand, memoryCommand, modelCommand, diff --git a/packages/cli/src/ui/commands/goalCommand.test.ts b/packages/cli/src/ui/commands/goalCommand.test.ts new file mode 100644 index 000000000..81fd5706f --- /dev/null +++ b/packages/cli/src/ui/commands/goalCommand.test.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { goalCommand, statusText } from './goalCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { GoalManager } from '@qwen-code/qwen-code-core'; + +function makeContext(manager: GoalManager): CommandContext { + return createMockCommandContext({ + services: { + config: { + getGoalManager: () => manager, + }, + }, + } as unknown as CommandContext); +} + +describe('goalCommand', () => { + let manager: GoalManager; + let ctx: CommandContext; + + beforeEach(() => { + manager = new GoalManager(); + ctx = makeContext(manager); + }); + + it('errors when config is not available', async () => { + const noConfigCtx = createMockCommandContext({ + services: { config: null }, + } as unknown as CommandContext); + const result = await goalCommand.action!(noConfigCtx, 'all tests pass'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringMatching(/config/i), + }); + }); + + describe('status mode (no args)', () => { + it('reports no active goal when none is set', async () => { + const result = await goalCommand.action!(ctx, ''); + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + }); + const content = (result as { content: string }).content; + expect(content).toMatch(/no active goal/i); + }); + + it('reports the active goal', async () => { + manager.setGoal('all tests pass'); + manager.recordTurn(); + manager.recordEvaluation({ + met: false, + reason: 'tests still failing', + tokensUsed: 42, + }); + const result = await goalCommand.action!(ctx, ''); + const content = (result as { content: string }).content; + expect(content).toMatch(/active goal/i); + expect(content).toMatch(/all tests pass/); + expect(content).toMatch(/tests still failing/); + }); + + it('reports the last achieved goal if no active goal', async () => { + manager.setGoal('cleanup'); + manager.markAchieved(); + const result = await goalCommand.action!(ctx, ''); + const content = (result as { content: string }).content; + expect(content).toMatch(/achieved/i); + expect(content).toMatch(/cleanup/); + }); + }); + + describe('clear mode', () => { + it.each(['clear', 'stop', 'off', 'reset', 'none', 'cancel'])( + 'accepts "%s" as a clear alias', + async (alias) => { + manager.setGoal('working on something'); + await goalCommand.action!(ctx, alias); + expect(manager.hasActiveGoal()).toBe(false); + }, + ); + + it('case-insensitive', async () => { + manager.setGoal('x'); + await goalCommand.action!(ctx, 'CLEAR'); + expect(manager.hasActiveGoal()).toBe(false); + }); + + it('reports "no active goal" when nothing was set', async () => { + const result = await goalCommand.action!(ctx, 'clear'); + expect((result as { content: string }).content).toMatch(/no active/i); + }); + }); + + describe('set mode', () => { + it('sets the goal and returns a submit_prompt for the first turn', async () => { + const result = await goalCommand.action!( + ctx, + 'all tests in test/auth pass', + ); + expect(result).toEqual({ + type: 'submit_prompt', + content: 'all tests in test/auth pass', + }); + expect(manager.hasActiveGoal()).toBe(true); + expect(manager.getActiveGoal()?.condition).toBe( + 'all tests in test/auth pass', + ); + }); + + it('trims surrounding whitespace from the condition', async () => { + await goalCommand.action!(ctx, ' build is clean '); + expect(manager.getActiveGoal()?.condition).toBe('build is clean'); + }); + + it('rejects conditions over 4000 characters', async () => { + const big = 'x'.repeat(4001); + const result = await goalCommand.action!(ctx, big); + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + }); + expect((result as { content: string }).content).toMatch(/4000/); + expect(manager.hasActiveGoal()).toBe(false); + }); + + it('replaces an existing active goal', async () => { + manager.setGoal('first'); + await goalCommand.action!(ctx, 'second'); + expect(manager.getActiveGoal()?.condition).toBe('second'); + }); + }); +}); + +describe('goalCommand statusText', () => { + it('renders the no-goal message', () => { + expect(statusText(new GoalManager())).toMatch(/no active goal/i); + }); + + it('renders an active goal with recent reason', () => { + const m = new GoalManager(); + m.setGoal('build clean'); + m.recordTurn(); + m.recordEvaluation({ met: false, reason: 'lint failing', tokensUsed: 10 }); + const text = statusText(m); + expect(text).toMatch(/active goal/i); + expect(text).toMatch(/build clean/); + expect(text).toMatch(/lint failing/); + expect(text).toMatch(/1 turn/); + }); +}); diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts new file mode 100644 index 000000000..b21d3aab8 --- /dev/null +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + MessageActionReturn, + SlashCommand, + SubmitPromptActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { + GOAL_CLEAR_ALIASES, + MAX_GOAL_CONDITION_LENGTH, + type GoalManager, +} from '@qwen-code/qwen-code-core'; + +const CLEAR_TOKENS = new Set(GOAL_CLEAR_ALIASES); + +/** + * `/goal ` — set a completion condition. Claude keeps working + * turn after turn until a small fast model confirms the condition holds. + * `/goal` with no args reports status; `/goal clear` (or stop/off/reset/none/cancel) + * removes the active goal. + */ +export const goalCommand: SlashCommand = { + name: 'goal', + description: + 'set a completion condition; keep working until it holds (or "/goal clear" to cancel)', + kind: CommandKind.BUILT_IN, + action: async ( + context, + args, + ): Promise => { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Goal command requires an initialised config.', + }; + } + + const manager = config.getGoalManager(); + const trimmed = args.trim(); + + // /goal — status + if (!trimmed) { + return { + type: 'message', + messageType: 'info', + content: statusText(manager), + }; + } + + // /goal clear (+ aliases) + if (CLEAR_TOKENS.has(trimmed.toLowerCase())) { + const cleared = manager.clearGoal(); + return { + type: 'message', + messageType: 'info', + content: cleared + ? `Cleared goal after ${cleared.turnCount} turn(s): "${cleared.condition}"` + : 'No active goal to clear.', + }; + } + + // /goal — set + fire first turn + if (trimmed.length > MAX_GOAL_CONDITION_LENGTH) { + return { + type: 'message', + messageType: 'error', + content: `Goal condition exceeds ${MAX_GOAL_CONDITION_LENGTH} characters (got ${trimmed.length}).`, + }; + } + + try { + manager.setGoal(trimmed); + } catch (err) { + return { + type: 'message', + messageType: 'error', + content: err instanceof Error ? err.message : String(err), + }; + } + + // The condition itself is the directive for the first turn. + return { type: 'submit_prompt', content: trimmed }; + }, +}; + +export function statusText(manager: GoalManager): string { + const active = manager.getActiveGoal(); + if (active) { + const elapsed = formatElapsed(Date.now() - active.startedAt); + const lines = [ + `Active goal (running ${elapsed}, ${active.turnCount} turn(s), ${active.tokensSpent} eval tokens):`, + ` ${active.condition}`, + ]; + if (active.lastReason) { + lines.push(`Last evaluator reason: ${active.lastReason}`); + } + return lines.join('\n'); + } + + const achieved = manager.getLastAchievedGoal(); + if (achieved && achieved.achievedAt) { + const elapsed = formatElapsed(achieved.achievedAt - achieved.startedAt); + return [ + `Last goal achieved in ${elapsed} (${achieved.turnCount} turn(s), ${achieved.tokensSpent} eval tokens):`, + ` ${achieved.condition}`, + ].join('\n'); + } + + return 'No active goal. Set one with `/goal `.'; +} + +function formatElapsed(ms: number): string { + if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s`; + if (ms < 60 * 60_000) return `${Math.round(ms / 60_000)}m`; + const hours = Math.floor(ms / (60 * 60_000)); + const mins = Math.round((ms - hours * 60 * 60_000) / 60_000); + return mins ? `${hours}h${mins}m` : `${hours}h`; +} diff --git a/packages/cli/src/ui/commands/loopCommand.test.ts b/packages/cli/src/ui/commands/loopCommand.test.ts new file mode 100644 index 000000000..e4ccbab11 --- /dev/null +++ b/packages/cli/src/ui/commands/loopCommand.test.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { loopCommand, parseScheduleArgs, listText } from './loopCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { CronScheduler } from '@qwen-code/qwen-code-core'; + +function makeContext( + scheduler: CronScheduler, + cronEnabled: boolean = true, +): CommandContext { + return createMockCommandContext({ + services: { + config: { + isCronEnabled: () => cronEnabled, + getCronScheduler: () => scheduler, + }, + }, + ui: { addItem: vi.fn() }, + } as unknown as CommandContext); +} + +describe('parseScheduleArgs', () => { + it('parses leading compact interval', () => { + expect(parseScheduleArgs('5m check deploy')).toEqual({ + intervalMs: 5 * 60 * 1000, + prompt: 'check deploy', + }); + }); + + it('parses leading two-token interval', () => { + expect(parseScheduleArgs('5 minutes check deploy')).toEqual({ + intervalMs: 5 * 60 * 1000, + prompt: 'check deploy', + }); + }); + + it('parses trailing "every "', () => { + expect(parseScheduleArgs('check deploy every 30m')).toEqual({ + intervalMs: 30 * 60 * 1000, + prompt: 'check deploy', + }); + }); + + it('parses trailing "every "', () => { + expect(parseScheduleArgs('run tests every 5 minutes')).toEqual({ + intervalMs: 5 * 60 * 1000, + prompt: 'run tests', + }); + }); + + it('does not interpret prepositions as intervals', () => { + // "check every PR" — "every" is not followed by a time expression. + expect(parseScheduleArgs('check every PR')).toEqual({ + intervalMs: null, + prompt: 'check every PR', + }); + }); + + it('returns intervalMs: null when no interval is present', () => { + expect(parseScheduleArgs('just keep an eye on things')).toEqual({ + intervalMs: null, + prompt: 'just keep an eye on things', + }); + }); + + it('returns empty prompt for empty args', () => { + expect(parseScheduleArgs('')).toEqual({ intervalMs: null, prompt: '' }); + }); + + it('handles leading interval with no prompt', () => { + expect(parseScheduleArgs('30m')).toEqual({ + intervalMs: 30 * 60 * 1000, + prompt: '', + }); + }); +}); + +describe('loopCommand', () => { + let scheduler: CronScheduler; + let ctx: CommandContext; + + beforeEach(() => { + scheduler = new CronScheduler(); + ctx = makeContext(scheduler); + }); + + it('errors when config is not available', async () => { + const noConfigCtx = createMockCommandContext({ + services: { config: null }, + } as unknown as CommandContext); + const result = await loopCommand.action!(noConfigCtx, '5m do thing'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + }); + + it('errors when cron is not enabled', async () => { + const disabledCtx = makeContext(scheduler, false); + const result = await loopCommand.action!(disabledCtx, '5m do thing'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect((result as { content: string }).content).toMatch(/cron/i); + }); + + describe('list mode', () => { + it('reports "no active loops" when scheduler is empty', async () => { + const result = await loopCommand.action!(ctx, ''); + expect((result as { content: string }).content).toMatch( + /no active loops/i, + ); + }); + + it('reports "no active loops" via /loop list', async () => { + const result = await loopCommand.action!(ctx, 'list'); + expect((result as { content: string }).content).toMatch( + /no active loops/i, + ); + }); + + it('lists scheduled jobs', async () => { + scheduler.create('*/5 * * * *', 'check deploy', true); + const result = await loopCommand.action!(ctx, 'list'); + const content = (result as { content: string }).content; + expect(content).toMatch(/active loop/i); + expect(content).toMatch(/check deploy/); + }); + }); + + describe('stop / clear', () => { + it.each(['stop', 'off', 'clear', 'cancel'])( + 'cancels all jobs via "/loop %s"', + async (alias) => { + scheduler.create('*/5 * * * *', 'a', true); + scheduler.create('*/10 * * * *', 'b', true); + const result = await loopCommand.action!(ctx, alias); + expect(scheduler.list()).toHaveLength(0); + expect((result as { content: string }).content).toMatch(/cancelled 2/i); + }, + ); + + it('reports "no active loops" when nothing was scheduled', async () => { + const result = await loopCommand.action!(ctx, 'stop'); + expect((result as { content: string }).content).toMatch( + /no active loops/i, + ); + }); + }); + + describe('cancel single job by id', () => { + it('cancels the matching job', async () => { + const job = scheduler.create('*/5 * * * *', 'foo', true); + const result = await loopCommand.action!(ctx, job.id); + expect(scheduler.list()).toHaveLength(0); + expect((result as { content: string }).content).toMatch( + new RegExp(job.id), + ); + }); + + it('reports "no loop with id" when the id is unknown', async () => { + const result = await loopCommand.action!(ctx, 'abc12345'); + expect((result as { content: string }).content).toMatch( + /no loop with id abc12345/i, + ); + }); + }); + + describe('schedule mode', () => { + it('schedules a recurring job and submits the first iteration', async () => { + const result = await loopCommand.action!(ctx, '5m check deploy'); + expect(result).toEqual({ + type: 'submit_prompt', + content: 'check deploy', + }); + const jobs = scheduler.list(); + expect(jobs).toHaveLength(1); + expect(jobs[0].cronExpr).toBe('*/5 * * * *'); + expect(jobs[0].prompt).toBe('check deploy'); + expect(jobs[0].recurring).toBe(true); + }); + + it('defaults to 10m when no interval is supplied', async () => { + await loopCommand.action!(ctx, 'just keep watching'); + expect(scheduler.list()[0].cronExpr).toBe('*/10 * * * *'); + expect(scheduler.list()[0].prompt).toBe('just keep watching'); + }); + + it('supports trailing "every" clause', async () => { + await loopCommand.action!(ctx, 'check the build every 2 hours'); + const job = scheduler.list()[0]; + expect(job.cronExpr).toBe('0 */2 * * *'); + expect(job.prompt).toBe('check the build'); + }); + + it('errors on interval-only input', async () => { + const result = await loopCommand.action!(ctx, '30m'); + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + }); + expect((result as { content: string }).content).toMatch(/prompt/i); + expect(scheduler.list()).toHaveLength(0); + }); + + it('echoes the cadence via ui.addItem', async () => { + const addItem = ctx.ui.addItem as ReturnType; + await loopCommand.action!(ctx, '5m check deploy'); + expect(addItem).toHaveBeenCalledTimes(1); + const [item] = addItem.mock.calls[0]; + expect(item.text).toMatch(/scheduled/i); + expect(item.text).toMatch(/every 5 minute/); + }); + }); +}); + +describe('listText', () => { + it('renders no-loops message', () => { + expect(listText(new CronScheduler())).toMatch(/no active loops/i); + }); + + it('renders an active job line', () => { + const s = new CronScheduler(); + s.create('*/5 * * * *', 'check deploy', true); + const text = listText(s); + expect(text).toMatch(/check deploy/); + expect(text).toMatch(/active loop/i); + }); +}); diff --git a/packages/cli/src/ui/commands/loopCommand.ts b/packages/cli/src/ui/commands/loopCommand.ts new file mode 100644 index 000000000..9fdca8507 --- /dev/null +++ b/packages/cli/src/ui/commands/loopCommand.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + MessageActionReturn, + SlashCommand, + SubmitPromptActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import { + LOOP_STOP_ALIASES, + DEFAULT_LOOP_INTERVAL_MS, + intervalMsToCron, + tryParseInterval, + type CronJob, + type CronScheduler, + humanReadableCron, +} from '@qwen-code/qwen-code-core'; + +const STOP_TOKENS = new Set(LOOP_STOP_ALIASES); +const LIST_TOKENS = new Set(['list', 'ls']); +const JOB_ID_RE = /^[a-z0-9]{8}$/; + +/** + * `/loop [interval] ` — schedule a recurring prompt via the cron + * scheduler. The first iteration fires immediately; subsequent iterations + * fire on the chosen cadence (default 10m if no interval is given). + * + * Other forms: + * - `/loop` — list active jobs (or report "none") + * - `/loop list` — explicit list + * - `/loop clear` — cancel all active jobs (aliases: stop, off, cancel) + * - `/loop ` — cancel a specific job by its 8-character id + * + * Supports the same parsing rules as the bundled `loop` skill so the model's + * prior behaviour stays consistent for users. + */ +export const loopCommand: SlashCommand = { + name: 'loop', + description: + 'schedule a recurring prompt (e.g. "/loop 5m check deploy"; /loop list to inspect; /loop stop to cancel all)', + kind: CommandKind.BUILT_IN, + action: async ( + context, + args, + ): Promise => { + const config = context.services.config; + if (!config) { + return err('Loop command requires an initialised config.'); + } + if (!config.isCronEnabled()) { + return err( + 'Scheduling is disabled. Enable it with `experimental.cron: true` in settings or `PROTO_ENABLE_CRON=1`.', + ); + } + + const scheduler = config.getCronScheduler(); + const trimmed = args.trim(); + + // /loop — list (or "no jobs" message) + if (!trimmed) { + return info(listText(scheduler)); + } + + const lower = trimmed.toLowerCase(); + + // /loop list | /loop ls + if (LIST_TOKENS.has(lower)) { + return info(listText(scheduler)); + } + + // /loop clear | stop | off | cancel + if (STOP_TOKENS.has(lower)) { + const cancelled = cancelAll(scheduler); + return info( + cancelled === 0 + ? 'No active loops to stop.' + : `Cancelled ${cancelled} loop${cancelled === 1 ? '' : 's'}.`, + ); + } + + // /loop — single-token job-id delete + if (JOB_ID_RE.test(trimmed)) { + const ok = scheduler.delete(trimmed); + return info( + ok ? `Cancelled loop ${trimmed}.` : `No loop with id ${trimmed}.`, + ); + } + + // /loop [interval] + const parsed = parseScheduleArgs(trimmed); + if (parsed.prompt.length === 0) { + return err( + 'Loop requires a prompt. Usage: `/loop [interval] ` (interval defaults to 10m).', + ); + } + + let cron: ReturnType; + try { + cron = parsed.intervalMs + ? intervalMsToCron(parsed.intervalMs) + : intervalMsToCron(DEFAULT_LOOP_INTERVAL_MS); + } catch (e) { + return err(e instanceof Error ? e.message : String(e)); + } + + let job; + try { + job = scheduler.create(cron.cron, parsed.prompt, /* recurring */ true); + } catch (e) { + return err(e instanceof Error ? e.message : String(e)); + } + + // Echo what we scheduled so the user sees the cadence we picked, then + // fire the first iteration immediately so the work starts now. + const roundedNote = cron.rounded ? ` (rounded to ${cron.description})` : ''; + context.ui.addItem( + { + type: MessageType.INFO, + text: + `Scheduled ${job.id} ${cron.description}${roundedNote}: ${parsed.prompt}\n` + + ` Use /loop list to inspect, /loop ${job.id} to cancel this one, or /loop stop to cancel all.`, + }, + Date.now(), + ); + + return { type: 'submit_prompt', content: parsed.prompt }; + }, +}; + +interface ParsedScheduleArgs { + intervalMs: number | null; + prompt: string; +} + +/** + * Parse `[interval] ` or ` every `. Returns + * `intervalMs: null` to mean "no interval supplied; caller should default". + */ +export function parseScheduleArgs(args: string): ParsedScheduleArgs { + const tokens = args.trim().split(/\s+/); + if (tokens.length === 0 || tokens[0] === '') { + return { intervalMs: null, prompt: '' }; + } + + // Rule 1: leading single-token interval ("5m", "30s") + const leadOne = tryParseInterval(tokens[0]); + if (leadOne !== null) { + return { intervalMs: leadOne, prompt: tokens.slice(1).join(' ') }; + } + + // Rule 1b: leading two-token interval ("5 minutes") + if (tokens.length >= 2) { + const leadTwo = tryParseInterval(`${tokens[0]} ${tokens[1]}`); + if (leadTwo !== null) { + return { intervalMs: leadTwo, prompt: tokens.slice(2).join(' ') }; + } + } + + // Rule 2: trailing "every " or "every " + const trailingMatch = args.match( + /\s+every\s+(\d+(?:\.\d+)?\s*[a-zA-Z]+)\s*$/i, + ); + if (trailingMatch) { + const everyMs = tryParseInterval(trailingMatch[1]); + if (everyMs !== null) { + const promptOnly = args.slice(0, trailingMatch.index ?? 0).trim(); + return { intervalMs: everyMs, prompt: promptOnly }; + } + } + + // Rule 3: no interval — caller substitutes default + return { intervalMs: null, prompt: args.trim() }; +} + +export function listText(scheduler: CronScheduler): string { + const jobs = scheduler.list(); + if (jobs.length === 0) { + return 'No active loops. Start one with `/loop [interval] `.'; + } + const lines = [`${jobs.length} active loop${jobs.length === 1 ? '' : 's'}:`]; + for (const job of jobs) { + lines.push( + ` [${job.id}] ${humanReadableCron(job.cronExpr)}: ${truncate(job.prompt, 80)}`, + ); + } + lines.push('Use `/loop ` to cancel one, or `/loop stop` to cancel all.'); + return lines.join('\n'); +} + +function cancelAll(scheduler: CronScheduler): number { + const jobs = scheduler.list(); + let n = 0; + for (const job of jobs) { + if (scheduler.delete(job.id)) n++; + } + return n; +} + +function info(content: string): MessageActionReturn { + return { type: 'message', messageType: 'info', content }; +} + +function err(content: string): MessageActionReturn { + return { type: 'message', messageType: 'error', content }; +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} + +// Re-export the CronJob type alias so test files don't need to dig for it. +export type { CronJob }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 119f47f87..d69738220 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -42,6 +42,7 @@ import { } from '../services/fileSystemService.js'; import { GitService } from '../services/gitService.js'; import { CronScheduler } from '../services/cronScheduler.js'; +import { GoalManager } from '../goal/index.js'; import { PermissionBlockerService } from '../services/permissionBlockerService.js'; import { SprintContractService } from '../services/sprintContractService.js'; @@ -683,6 +684,7 @@ export class Config { private readonly hooks?: Record; private hookSystem?: HookSystem; private messageBus?: MessageBus; + private readonly goalManager: GoalManager = new GoalManager(); constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); @@ -1261,6 +1263,9 @@ export class Config { this.chatRecordingService = this.chatRecordingEnabled ? new ChatRecordingService(this) : undefined; + // Goal state is session-scoped; reset on new session. /loop state lives + // on the CronScheduler, which has its own lifecycle. + this.goalManager.reset(); if (this.initialized) { logStartSession(this, new StartSessionEvent(this)); } @@ -1929,6 +1934,13 @@ export class Config { return this.hookSystem; } + /** + * Get the session-scoped goal manager. Backs the `/goal` slash command. + */ + getGoalManager(): GoalManager { + return this.goalManager; + } + /** * Fast-path check: returns true only when hooks are enabled AND there are * registered hooks for the given event name. Callers can use this to skip diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index e647d34ff..e012a3a2a 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -103,6 +103,7 @@ import { CompletionChecker, type ToolCallRecord, } from '../hooks/completion-checker.js'; +import { evaluateGoal } from '../goal/index.js'; const MAX_TURNS = 100; @@ -985,6 +986,75 @@ export class GeminiClient { } } + // Evaluate any active /goal against the just-finished turn. If the + // condition is not met, inject the evaluator's reason as guidance and + // run another turn -- the same continuation pattern used above by the + // Stop hook and CompletionChecker paths. If met, mark achieved and let + // control return to the user. The optional-chaining call covers test + // mocks that don't stub getGoalManager. + const goalManager = this.config.getGoalManager?.(); + if ( + goalManager?.hasActiveGoal() && + !turn.pendingToolCalls.length && + signal && + !signal.aborted && + messageType !== SendMessageType.Hook + ) { + const goalHistory = this.getHistory(); + const goalToolCalls = this.extractToolCallHistory(goalHistory); + const lastGoalModel = goalHistory + .filter((msg) => msg.role === 'model') + .pop(); + const lastGoalAssistantMessage = + lastGoalModel?.parts + ?.filter((p): p is { text: string } => 'text' in p) + .map((p) => p.text) + .join('') || ''; + + const active = goalManager.getActiveGoal(); + if (active) { + goalManager.recordTurn(); + const toolCallSummary = goalToolCalls + .slice(-20) + .map((t) => { + const status = t.success ? 'ok' : 'failed'; + const cmd = + typeof t.input?.['command'] === 'string' + ? `: ${(t.input['command'] as string).slice(0, 200)}` + : ''; + return `- ${t.name} [${status}]${cmd}`; + }) + .join('\n'); + const evalResult = await evaluateGoal( + this.getContentGeneratorOrFail(), + this.config.getModel(), + { + condition: active.condition, + toolCallSummary, + lastAssistantMessage: lastGoalAssistantMessage, + }, + signal, + ); + goalManager.recordEvaluation(evalResult); + + if (evalResult.met) { + goalManager.markAchieved(); + } else { + const continueReason = `Goal not yet met. Evaluator: ${evalResult.reason}\n\nKeep working toward: ${active.condition}`; + const continueRequest = [{ text: continueReason }]; + const goalResult = yield* this.sendMessageStream( + continueRequest, + signal, + prompt_id, + { type: SendMessageType.Hook }, + boundedTurns - 1, + ); + if (ownsTurnSpan) endTurnSpan('ok'); + return goalResult; + } + } + } + if (!turn.pendingToolCalls.length && signal && !signal.aborted) { if (this.config.getSkipNextSpeakerCheck()) { // Report completed before returning — agent has no more work to do diff --git a/packages/core/src/goal/GoalManager.test.ts b/packages/core/src/goal/GoalManager.test.ts new file mode 100644 index 000000000..87570f9e1 --- /dev/null +++ b/packages/core/src/goal/GoalManager.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { GoalManager } from './GoalManager.js'; + +describe('GoalManager', () => { + let manager: GoalManager; + + beforeEach(() => { + manager = new GoalManager(); + }); + + describe('setGoal', () => { + it('creates a new active goal with zeroed counters', () => { + const state = manager.setGoal('all tests pass'); + expect(state.condition).toBe('all tests pass'); + expect(state.turnCount).toBe(0); + expect(state.tokensSpent).toBe(0); + expect(state.achievedAt).toBeUndefined(); + expect(state.startedAt).toBeGreaterThan(0); + expect(manager.hasActiveGoal()).toBe(true); + }); + + it('trims surrounding whitespace from the condition', () => { + const state = manager.setGoal(' lint clean '); + expect(state.condition).toBe('lint clean'); + }); + + it('rejects empty conditions', () => { + expect(() => manager.setGoal('')).toThrow(/empty/); + expect(() => manager.setGoal(' ')).toThrow(/empty/); + }); + + it('rejects conditions over 4000 characters', () => { + expect(() => manager.setGoal('x'.repeat(4001))).toThrow(/4000/); + }); + + it('replaces an existing active goal', () => { + manager.setGoal('first'); + manager.recordTurn(); + manager.setGoal('second'); + const active = manager.getActiveGoal(); + expect(active?.condition).toBe('second'); + expect(active?.turnCount).toBe(0); + }); + }); + + describe('clearGoal', () => { + it('returns undefined when no goal is active', () => { + expect(manager.clearGoal()).toBeUndefined(); + }); + + it('clears an active goal and returns its final state', () => { + manager.setGoal('cleanup queue'); + manager.recordTurn(); + const cleared = manager.clearGoal(); + expect(cleared?.condition).toBe('cleanup queue'); + expect(cleared?.turnCount).toBe(1); + expect(manager.hasActiveGoal()).toBe(false); + }); + + it('does not affect the lastAchieved record', () => { + manager.setGoal('done'); + manager.markAchieved(); + manager.setGoal('next'); + manager.clearGoal(); + expect(manager.getLastAchievedGoal()?.condition).toBe('done'); + }); + }); + + describe('markAchieved', () => { + it('moves the active goal into lastAchieved with achievedAt set', () => { + manager.setGoal('migrate module'); + manager.recordTurn(); + const achieved = manager.markAchieved(); + expect(achieved?.achievedAt).toBeGreaterThan(0); + expect(manager.hasActiveGoal()).toBe(false); + expect(manager.getLastAchievedGoal()?.condition).toBe('migrate module'); + }); + + it('returns undefined when no goal is active', () => { + expect(manager.markAchieved()).toBeUndefined(); + }); + + it('overwrites a prior achieved goal', () => { + manager.setGoal('first'); + manager.markAchieved(); + manager.setGoal('second'); + manager.markAchieved(); + expect(manager.getLastAchievedGoal()?.condition).toBe('second'); + }); + }); + + describe('recordTurn', () => { + it('increments the active goal turn count', () => { + manager.setGoal('x'); + manager.recordTurn(); + manager.recordTurn(); + expect(manager.getActiveGoal()?.turnCount).toBe(2); + }); + + it('is a no-op when no goal is active', () => { + manager.recordTurn(); + expect(manager.hasActiveGoal()).toBe(false); + }); + }); + + describe('recordEvaluation', () => { + it('stores the latest reason and accumulates tokens', () => { + manager.setGoal('x'); + manager.recordEvaluation({ + met: false, + reason: 'tests not run yet', + tokensUsed: 50, + }); + manager.recordEvaluation({ + met: false, + reason: 'still failing', + tokensUsed: 30, + }); + const active = manager.getActiveGoal(); + expect(active?.lastReason).toBe('still failing'); + expect(active?.tokensSpent).toBe(80); + }); + + it('is a no-op when no goal is active', () => { + manager.recordEvaluation({ met: true, reason: 'ok', tokensUsed: 10 }); + expect(manager.hasActiveGoal()).toBe(false); + }); + }); + + describe('reset', () => { + it('clears both active and achieved state', () => { + manager.setGoal('first'); + manager.markAchieved(); + manager.setGoal('second'); + manager.reset(); + expect(manager.hasActiveGoal()).toBe(false); + expect(manager.getLastAchievedGoal()).toBeUndefined(); + }); + }); + + describe('getActiveGoal', () => { + it('returns a copy that does not mutate internal state', () => { + manager.setGoal('x'); + const snapshot = manager.getActiveGoal(); + if (snapshot) snapshot.turnCount = 999; + expect(manager.getActiveGoal()?.turnCount).toBe(0); + }); + }); +}); diff --git a/packages/core/src/goal/GoalManager.ts b/packages/core/src/goal/GoalManager.ts new file mode 100644 index 000000000..64a594d4e --- /dev/null +++ b/packages/core/src/goal/GoalManager.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '../utils/debugLogger.js'; +import { + MAX_GOAL_CONDITION_LENGTH, + type GoalEvaluationResult, + type GoalState, +} from './types.js'; + +const debugLogger = createDebugLogger('GOAL_MANAGER'); + +/** + * Session-scoped goal state. One goal can be active at a time; setting a new + * goal replaces the previous one. The previous achieved goal (if any) is kept + * so `/goal` with no args can report on it after success. + */ +export class GoalManager { + private active: GoalState | undefined; + private lastAchieved: GoalState | undefined; + + /** + * Set a new goal. Replaces any active goal. Returns the created state. + * Throws if the condition is empty or exceeds the length limit. + */ + setGoal(condition: string): GoalState { + const trimmed = condition.trim(); + if (!trimmed) { + throw new Error('Goal condition cannot be empty.'); + } + if (trimmed.length > MAX_GOAL_CONDITION_LENGTH) { + throw new Error( + `Goal condition exceeds ${MAX_GOAL_CONDITION_LENGTH} characters.`, + ); + } + + if (this.active) { + debugLogger.info( + `Replacing active goal "${truncate(this.active.condition, 40)}" with "${truncate(trimmed, 40)}".`, + ); + } + + this.active = { + condition: trimmed, + startedAt: Date.now(), + turnCount: 0, + tokensSpent: 0, + }; + return this.active; + } + + /** + * Clear the active goal without marking it achieved. Returns the cleared + * state if one was active. + */ + clearGoal(): GoalState | undefined { + if (!this.active) return undefined; + const cleared = this.active; + this.active = undefined; + debugLogger.info( + `Cleared goal "${truncate(cleared.condition, 60)}" after ${cleared.turnCount} turns.`, + ); + return cleared; + } + + /** + * Mark the active goal as achieved and move it to `lastAchieved`. Returns + * the achieved record. No-op if no goal is active. + */ + markAchieved(): GoalState | undefined { + if (!this.active) return undefined; + const achieved: GoalState = { + ...this.active, + achievedAt: Date.now(), + }; + this.lastAchieved = achieved; + this.active = undefined; + debugLogger.info( + `Goal achieved after ${achieved.turnCount} turns: "${truncate(achieved.condition, 60)}".`, + ); + return achieved; + } + + /** Record that the agent completed a turn while the goal was active. */ + recordTurn(): void { + if (this.active) { + this.active = { ...this.active, turnCount: this.active.turnCount + 1 }; + } + } + + /** Record the result of an evaluation against the active goal. */ + recordEvaluation(result: GoalEvaluationResult): void { + if (!this.active) return; + this.active = { + ...this.active, + lastReason: result.reason, + tokensSpent: this.active.tokensSpent + result.tokensUsed, + }; + } + + hasActiveGoal(): boolean { + return this.active !== undefined; + } + + getActiveGoal(): GoalState | undefined { + return this.active ? { ...this.active } : undefined; + } + + getLastAchievedGoal(): GoalState | undefined { + return this.lastAchieved ? { ...this.lastAchieved } : undefined; + } + + /** Reset both active and achieved state. Used on session clear/end. */ + reset(): void { + this.active = undefined; + this.lastAchieved = undefined; + } +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} diff --git a/packages/core/src/goal/goalEvaluator.test.ts b/packages/core/src/goal/goalEvaluator.test.ts new file mode 100644 index 000000000..5468b7ffd --- /dev/null +++ b/packages/core/src/goal/goalEvaluator.test.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { evaluateGoal, parseEvaluatorJson } from './goalEvaluator.js'; +import type { ContentGenerator } from '../core/contentGenerator.js'; + +function mockGenerator( + textResponse: string, + tokens: number = 42, +): ContentGenerator { + return { + generateContent: vi.fn().mockResolvedValue({ + candidates: [{ content: { parts: [{ text: textResponse }] } }], + usageMetadata: { totalTokenCount: tokens }, + }), + generateContentStream: vi.fn(), + countTokens: vi.fn(), + embedContent: vi.fn(), + useSummarizedThinking: () => false, + } as unknown as ContentGenerator; +} + +describe('parseEvaluatorJson', () => { + it('parses a clean JSON object', () => { + const r = parseEvaluatorJson('{"met": true, "reason": "all tests pass"}'); + expect(r.met).toBe(true); + expect(r.reason).toBe('all tests pass'); + }); + + it('strips surrounding code fences', () => { + const r = parseEvaluatorJson( + '```json\n{"met": false, "reason": "tests still failing"}\n```', + ); + expect(r.met).toBe(false); + expect(r.reason).toBe('tests still failing'); + }); + + it('extracts JSON when prose surrounds the object', () => { + const r = parseEvaluatorJson( + 'Sure -- {"met": true, "reason": "done"} -- end', + ); + expect(r.met).toBe(true); + }); + + it('returns met=false on non-JSON responses', () => { + const r = parseEvaluatorJson('I have no idea, sorry.'); + expect(r.met).toBe(false); + expect(r.reason).toMatch(/not valid JSON/); + }); + + it('handles missing reason by supplying a default', () => { + const r = parseEvaluatorJson('{"met": true}'); + expect(r.met).toBe(true); + expect(r.reason).toBe('Condition met.'); + }); + + it('treats truthy non-true values as not met', () => { + const r = parseEvaluatorJson('{"met": "yes", "reason": "fuzzy"}'); + expect(r.met).toBe(false); + }); +}); + +describe('evaluateGoal', () => { + const ctx = { + condition: 'all tests pass', + toolCallSummary: 'npm test -> exit 0', + lastAssistantMessage: 'Tests are passing.', + }; + + it('returns met=true when the model says so', async () => { + const gen = mockGenerator('{"met": true, "reason": "tests passed"}', 100); + const r = await evaluateGoal(gen, 'haiku', ctx); + expect(r.met).toBe(true); + expect(r.reason).toBe('tests passed'); + expect(r.tokensUsed).toBe(100); + }); + + it('returns met=false when the model says so', async () => { + const gen = mockGenerator( + '{"met": false, "reason": "tests still red"}', + 55, + ); + const r = await evaluateGoal(gen, 'haiku', ctx); + expect(r.met).toBe(false); + expect(r.reason).toBe('tests still red'); + }); + + it('returns met=false on empty response', async () => { + const gen = mockGenerator('', 0); + const r = await evaluateGoal(gen, 'haiku', ctx); + expect(r.met).toBe(false); + expect(r.reason).toMatch(/empty/); + }); + + it('returns met=false when the generator throws', async () => { + const gen = { + generateContent: vi.fn().mockRejectedValue(new Error('network down')), + generateContentStream: vi.fn(), + countTokens: vi.fn(), + embedContent: vi.fn(), + useSummarizedThinking: () => false, + } as unknown as ContentGenerator; + const r = await evaluateGoal(gen, 'haiku', ctx); + expect(r.met).toBe(false); + expect(r.reason).toMatch(/failed/); + }); + + it('honours an aborted signal as a cancellation', async () => { + const controller = new AbortController(); + controller.abort(); + const gen = { + generateContent: vi + .fn() + .mockRejectedValue(new DOMException('aborted', 'AbortError')), + generateContentStream: vi.fn(), + countTokens: vi.fn(), + embedContent: vi.fn(), + useSummarizedThinking: () => false, + } as unknown as ContentGenerator; + const r = await evaluateGoal(gen, 'haiku', ctx, controller.signal); + expect(r.met).toBe(false); + expect(r.reason).toMatch(/cancel/i); + }); +}); diff --git a/packages/core/src/goal/goalEvaluator.ts b/packages/core/src/goal/goalEvaluator.ts new file mode 100644 index 000000000..35fdd9570 --- /dev/null +++ b/packages/core/src/goal/goalEvaluator.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ContentGenerator } from '../core/contentGenerator.js'; +import type { GoalEvaluationContext, GoalEvaluationResult } from './types.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('GOAL_EVALUATOR'); + +const EVALUATOR_INSTRUCTIONS = `You are judging whether a user's completion condition has been satisfied by an AI coding assistant. + +You only see what the assistant has surfaced in the transcript -- tool calls it ran, their outcomes, and the final assistant message. You do not run commands yourself. + +Respond ONLY with valid JSON matching exactly this schema: +{"met": boolean, "reason": "one short sentence explaining why"} + +Rules: +- Set "met": true only when the condition is clearly demonstrated by the visible evidence (a test output, an exit code, a file count, an empty queue, etc.). +- If the assistant only claims the work is done without showing evidence, set "met": false and ask for the missing evidence in "reason". +- If the condition cannot be verified from what is shown, set "met": false and explain what would prove it in "reason". +- Keep "reason" to one short sentence. It will be shown to the assistant as guidance for the next turn.`; + +/** + * Evaluate whether a goal condition has been met, using the configured small + * model. Returns "not met" with the failure reason on any error so the loop + * stays safe -- callers can manually clear the goal if it gets stuck. + */ +export async function evaluateGoal( + generator: ContentGenerator, + model: string, + context: GoalEvaluationContext, + signal?: AbortSignal, +): Promise { + const userPrompt = buildEvaluatorPrompt(context); + + try { + const response = await generator.generateContent( + { + model, + contents: [{ role: 'user', parts: [{ text: userPrompt }] }], + config: { + abortSignal: signal, + thinkingConfig: { includeThoughts: false }, + // tools: [] (truthy) bypasses tool-stripping in the request pipeline. + tools: [], + temperature: 0, + }, + }, + 'goal-evaluator', + ); + + const text = response.candidates?.[0]?.content?.parts + ?.map((p) => p.text ?? '') + .join('') + .trim(); + + const tokensUsed = + response.usageMetadata?.totalTokenCount ?? + response.usageMetadata?.candidatesTokenCount ?? + 0; + + if (!text) { + return { + met: false, + reason: 'Evaluator returned an empty response; will retry next turn.', + tokensUsed, + }; + } + + const parsed = parseEvaluatorJson(text); + return { ...parsed, tokensUsed }; + } catch (error) { + if (signal?.aborted) { + return { met: false, reason: 'Evaluation cancelled.', tokensUsed: 0 }; + } + debugLogger.warn( + `goal evaluator failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return { + met: false, + reason: 'Evaluator call failed; continuing to next turn.', + tokensUsed: 0, + }; + } +} + +function buildEvaluatorPrompt(context: GoalEvaluationContext): string { + return [ + EVALUATOR_INSTRUCTIONS, + '', + 'Condition:', + context.condition, + '', + 'Recent tool calls:', + context.toolCallSummary || '(none)', + '', + 'Final assistant message:', + context.lastAssistantMessage || '(empty)', + ].join('\n'); +} + +/** + * Parse the evaluator response. Tolerates surrounding prose and code-fence + * markers; falls back to a "not met" result if nothing parseable is found. + */ +export function parseEvaluatorJson( + text: string, +): Pick { + const cleaned = stripCodeFence(text); + const jsonObj = extractFirstJsonObject(cleaned); + if (!jsonObj) { + return { + met: false, + reason: `Evaluator response was not valid JSON; raw: ${truncate(text, 120)}`, + }; + } + + try { + const parsed = JSON.parse(jsonObj) as { + met?: unknown; + reason?: unknown; + }; + const met = parsed.met === true; + const reason = + typeof parsed.reason === 'string' && parsed.reason.trim().length > 0 + ? parsed.reason.trim() + : met + ? 'Condition met.' + : 'No reason provided.'; + return { met, reason }; + } catch { + return { + met: false, + reason: `Evaluator response was not valid JSON; raw: ${truncate(text, 120)}`, + }; + } +} + +function stripCodeFence(text: string): string { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + return fenced ? fenced[1].trim() : text.trim(); +} + +function extractFirstJsonObject(text: string): string | null { + const start = text.indexOf('{'); + if (start === -1) return null; + let depth = 0; + for (let i = start; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}') { + depth--; + if (depth === 0) return text.slice(start, i + 1); + } + } + return null; +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} diff --git a/packages/core/src/goal/index.ts b/packages/core/src/goal/index.ts new file mode 100644 index 000000000..b1a74fdda --- /dev/null +++ b/packages/core/src/goal/index.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { GoalManager } from './GoalManager.js'; +export { evaluateGoal, parseEvaluatorJson } from './goalEvaluator.js'; +export { + GOAL_CLEAR_ALIASES, + MAX_GOAL_CONDITION_LENGTH, + type GoalClearAlias, + type GoalEvaluationContext, + type GoalEvaluationResult, + type GoalState, +} from './types.js'; diff --git a/packages/core/src/goal/types.ts b/packages/core/src/goal/types.ts new file mode 100644 index 000000000..63b2b35cb --- /dev/null +++ b/packages/core/src/goal/types.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Maximum length of a goal condition. Mirrors Claude Code's `/goal` limit. + */ +export const MAX_GOAL_CONDITION_LENGTH = 4000; + +/** + * Argument tokens that clear an active goal. Accepted by `/goal `. + */ +export const GOAL_CLEAR_ALIASES = [ + 'clear', + 'stop', + 'off', + 'reset', + 'none', + 'cancel', +] as const; + +export type GoalClearAlias = (typeof GOAL_CLEAR_ALIASES)[number]; + +export interface GoalState { + /** The condition the agent is working toward. */ + condition: string; + /** Unix-ms timestamp when the goal was set. */ + startedAt: number; + /** Number of turns evaluated since the goal was set. */ + turnCount: number; + /** The most recent evaluator reason (why the goal is or is not met). */ + lastReason?: string; + /** Tokens spent on goal evaluation (excludes main-turn tokens). */ + tokensSpent: number; + /** Unix-ms timestamp when the goal was achieved. Unset on active goals. */ + achievedAt?: number; +} + +export interface GoalEvaluationContext { + /** The user's completion condition. */ + condition: string; + /** Plain-text summary of the most recent tool calls. */ + toolCallSummary: string; + /** The final assistant message from the most recent turn. */ + lastAssistantMessage: string; +} + +export interface GoalEvaluationResult { + /** Whether the evaluator considers the condition satisfied. */ + met: boolean; + /** Short reason supporting the decision. */ + reason: string; + /** Tokens consumed by the evaluator call. */ + tokensUsed: number; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d4cb604e9..8c7bc259b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -65,6 +65,11 @@ export * from './core/prompts.js'; export * from './core/tokenLimits.js'; export * from './core/turn.js'; +// Goal & loop (long-horizon task primitives) +export * from './goal/index.js'; +export * from './loop/index.js'; +export { humanReadableCron } from './utils/cronDisplay.js'; + // ============================================================================ // Tools // ============================================================================ diff --git a/packages/core/src/loop/index.ts b/packages/core/src/loop/index.ts new file mode 100644 index 000000000..59d268118 --- /dev/null +++ b/packages/core/src/loop/index.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + parseInterval, + tryParseInterval, + formatInterval, +} from './intervalParser.js'; +export { + intervalToCron, + intervalMsToCron, + type IntervalToCronResult, +} from './intervalToCron.js'; +export { + LOOP_STOP_ALIASES, + MIN_LOOP_INTERVAL_MS, + DEFAULT_LOOP_INTERVAL_MS, + type LoopStopAlias, +} from './types.js'; diff --git a/packages/core/src/loop/intervalParser.test.ts b/packages/core/src/loop/intervalParser.test.ts new file mode 100644 index 000000000..91ff4038b --- /dev/null +++ b/packages/core/src/loop/intervalParser.test.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + parseInterval, + tryParseInterval, + formatInterval, +} from './intervalParser.js'; + +describe('parseInterval', () => { + it('parses compact tokens with each unit', () => { + expect(parseInterval('60s')).toBe(60_000); + expect(parseInterval('5m')).toBe(5 * 60 * 1000); + expect(parseInterval('2h')).toBe(2 * 60 * 60 * 1000); + expect(parseInterval('1d')).toBe(24 * 60 * 60 * 1000); + }); + + it('parses natural-language unit names', () => { + expect(parseInterval('5 minutes')).toBe(5 * 60 * 1000); + expect(parseInterval('2 hours')).toBe(2 * 60 * 60 * 1000); + expect(parseInterval('1 day')).toBe(24 * 60 * 60 * 1000); + }); + + it('tolerates surrounding whitespace and casing', () => { + expect(parseInterval(' 5M ')).toBe(5 * 60 * 1000); + expect(parseInterval('1H')).toBe(60 * 60 * 1000); + }); + + it('rejects intervals below 60 seconds', () => { + expect(() => parseInterval('30s')).toThrow(/at least 60/); + expect(() => parseInterval('0m')).toThrow(/at least 60/); + }); + + it('rejects garbage input', () => { + expect(() => parseInterval('soon')).toThrow(/parse/); + expect(() => parseInterval('5')).toThrow(/parse/); + expect(() => parseInterval('')).toThrow(); + }); +}); + +describe('tryParseInterval', () => { + it('returns ms on success', () => { + expect(tryParseInterval('5m')).toBe(5 * 60 * 1000); + }); + + it('returns null on failure', () => { + expect(tryParseInterval('check deploy')).toBeNull(); + expect(tryParseInterval('30s')).toBeNull(); // below minimum + }); +}); + +describe('formatInterval', () => { + it('picks the largest clean unit', () => { + expect(formatInterval(60_000)).toBe('1m'); + expect(formatInterval(5 * 60 * 1000)).toBe('5m'); + expect(formatInterval(60 * 60 * 1000)).toBe('1h'); + expect(formatInterval(24 * 60 * 60 * 1000)).toBe('1d'); + }); + + it('falls back to seconds for non-clean values', () => { + expect(formatInterval(90_000)).toBe('90s'); + }); +}); diff --git a/packages/core/src/loop/intervalParser.ts b/packages/core/src/loop/intervalParser.ts new file mode 100644 index 000000000..bba70e2ee --- /dev/null +++ b/packages/core/src/loop/intervalParser.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MIN_LOOP_INTERVAL_MS } from './types.js'; + +const UNIT_MS: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, +}; + +const COMPACT_RE = + /^\s*(\d+(?:\.\d+)?)\s*(s|m|h|d|sec|secs|min|mins|hr|hrs|day|days|second|seconds|minute|minutes|hour|hours)\s*$/i; + +/** + * Parse a duration token like `5m`, `30s`, `2h`, `1d` into milliseconds. Also + * accepts the long forms (`5 minutes`, `2 hours`). + * + * Throws on unparseable input or values below the 60s minimum. + */ +export function parseInterval(token: string): number { + if (!token || typeof token !== 'string') { + throw new Error('Interval is required.'); + } + + const match = token.match(COMPACT_RE); + if (!match) { + throw new Error( + `Could not parse interval "${token}". Use formats like 5m, 30m, 2h, or 1d.`, + ); + } + + const value = Number(match[1]); + const unit = normaliseUnit(match[2]); + const ms = Math.round(value * UNIT_MS[unit]); + + if (ms < MIN_LOOP_INTERVAL_MS) { + throw new Error(`Interval must be at least 60 seconds (got ${token}).`); + } + + return ms; +} + +/** + * Try to parse an interval; returns null on failure rather than throwing. Used + * by the CLI to detect "is the first argument an interval or part of the prompt?" + */ +export function tryParseInterval(token: string): number | null { + try { + return parseInterval(token); + } catch { + return null; + } +} + +/** + * Render an interval back into a compact, human-readable token like `5m` or + * `2h`. Picks the largest unit that produces an integer value. + */ +export function formatInterval(ms: number): string { + for (const [label, factor] of [ + ['d', UNIT_MS['d']], + ['h', UNIT_MS['h']], + ['m', UNIT_MS['m']], + ['s', UNIT_MS['s']], + ] as const) { + if (ms % factor === 0) return `${ms / factor}${label}`; + } + return `${Math.round(ms / UNIT_MS['s'])}s`; +} + +function normaliseUnit(raw: string): string { + const lower = raw.toLowerCase(); + if (lower.startsWith('s')) return 's'; + if (lower.startsWith('mi') || lower === 'm') return 'm'; + if (lower.startsWith('h')) return 'h'; + if (lower.startsWith('d')) return 'd'; + return lower; +} diff --git a/packages/core/src/loop/intervalToCron.test.ts b/packages/core/src/loop/intervalToCron.test.ts new file mode 100644 index 000000000..28ce8170b --- /dev/null +++ b/packages/core/src/loop/intervalToCron.test.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { intervalToCron, intervalMsToCron } from './intervalToCron.js'; + +describe('intervalToCron — minutes', () => { + it('5m → */5 * * * *', () => { + const r = intervalToCron('5m'); + expect(r.cron).toBe('*/5 * * * *'); + expect(r.rounded).toBe(false); + expect(r.description).toMatch(/every 5 minute/); + }); + + it('every clean divisor of 60 maps directly', () => { + expect(intervalToCron('1m').cron).toBe('*/1 * * * *'); + expect(intervalToCron('2m').cron).toBe('*/2 * * * *'); + expect(intervalToCron('15m').cron).toBe('*/15 * * * *'); + expect(intervalToCron('30m').cron).toBe('*/30 * * * *'); + }); + + it('7m rounds to the nearest clean step (6 or 10)', () => { + const r = intervalToCron('7m'); + expect(r.rounded).toBe(true); + expect(['*/6 * * * *', '*/10 * * * *']).toContain(r.cron); + }); +}); + +describe('intervalToCron — hours', () => { + it('2h → 0 */2 * * *', () => { + const r = intervalToCron('2h'); + expect(r.cron).toBe('0 */2 * * *'); + expect(r.rounded).toBe(false); + }); + + it('90m rounds to ~2h', () => { + const r = intervalToCron('90m'); + expect(r.rounded).toBe(true); + expect(r.cron).toBe('0 */2 * * *'); + }); + + it('clean divisors of 24 map directly', () => { + expect(intervalToCron('1h').cron).toBe('0 */1 * * *'); + expect(intervalToCron('3h').cron).toBe('0 */3 * * *'); + expect(intervalToCron('12h').cron).toBe('0 */12 * * *'); + }); +}); + +describe('intervalToCron — days', () => { + it('1d → 0 0 */1 * *', () => { + const r = intervalToCron('1d'); + expect(r.cron).toBe('0 0 */1 * *'); + expect(r.rounded).toBe(false); + }); + + it('7d → 0 0 */7 * *', () => { + expect(intervalToCron('7d').cron).toBe('0 0 */7 * *'); + }); +}); + +describe('intervalMsToCron', () => { + it('accepts pre-parsed ms values', () => { + expect(intervalMsToCron(5 * 60_000).cron).toBe('*/5 * * * *'); + expect(intervalMsToCron(60 * 60_000).cron).toBe('0 */1 * * *'); + }); + + it('rounds sub-minute values up to 1 minute', () => { + expect(intervalMsToCron(30_000).cron).toBe('*/1 * * * *'); + }); +}); diff --git a/packages/core/src/loop/intervalToCron.ts b/packages/core/src/loop/intervalToCron.ts new file mode 100644 index 000000000..f1c8cbd78 --- /dev/null +++ b/packages/core/src/loop/intervalToCron.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseInterval } from './intervalParser.js'; + +/** + * Result of converting a user-supplied interval token to a cron expression. + */ +export interface IntervalToCronResult { + /** The 5-field cron expression. */ + cron: string; + /** Human-readable description of the resolved cadence (for echo to user). */ + description: string; + /** True if the requested interval was rounded to the nearest clean cron step. */ + rounded: boolean; +} + +/** + * Convert a duration token like `5m`, `2h`, `1d` into a 5-field cron expression + * suitable for `CronScheduler.create`. Throws on values that can't be parsed + * or are below the 60-second minimum. + * + * Mapping (mirrors the bundled loop skill): + * + * | Pattern | Cron | Notes | + * | -------------------- | --------------------- | -------------------------------------- | + * | `Nm` where N ≤ 59 | `*\/N * * * *` | every N minutes | + * | `Nm` where N ≥ 60 | `0 *\/H * * *` | rounded to whole hours (H = N/60) | + * | `Nh` where N ≤ 23 | `0 *\/N * * *` | every N hours | + * | `Nd` | `0 0 *\/N * *` | midnight every N days | + * | `Ns` | round up to nearest m | cron granularity is 1 minute | + * + * For minute or hour values that don't cleanly divide their unit (e.g. `7m`, + * `90m`), the function picks the nearest clean step and sets `rounded: true` + * so the caller can warn the user. + */ +export function intervalToCron(token: string): IntervalToCronResult { + const ms = parseInterval(token); + return intervalMsToCron(ms); +} + +/** + * Same as `intervalToCron` but accepts a pre-parsed millisecond value. + */ +export function intervalMsToCron(ms: number): IntervalToCronResult { + const minutes = Math.max(1, Math.round(ms / 60_000)); + + if (minutes <= 59) { + const step = pickCleanDivisor(60, minutes); + return { + cron: `*/${step} * * * *`, + description: `every ${step} minute${step === 1 ? '' : 's'}`, + rounded: step !== minutes, + }; + } + + const hours = Math.round(minutes / 60); + if (hours <= 23) { + const step = pickCleanDivisor(24, hours); + return { + cron: `0 */${step} * * *`, + description: `every ${step} hour${step === 1 ? '' : 's'}`, + rounded: step !== hours || minutes !== hours * 60, + }; + } + + const days = Math.round(hours / 24); + return { + cron: `0 0 */${days} * *`, + description: `every ${days} day${days === 1 ? '' : 's'} at midnight`, + rounded: days !== hours / 24, + }; +} + +/** + * Pick the divisor of `total` closest to `target`. Cron `*\/N` only produces + * even gaps when N divides the period (60 for minutes, 24 for hours), so we + * round to the nearest clean step rather than emitting a misleading cron. + */ +function pickCleanDivisor(total: number, target: number): number { + if (target <= 0) return 1; + if (target >= total) return total; + let best = 1; + let bestDiff = Math.abs(target - 1); + for (let n = 1; n <= total; n++) { + if (total % n !== 0) continue; + const diff = Math.abs(target - n); + if (diff < bestDiff) { + best = n; + bestDiff = diff; + } + } + return best; +} diff --git a/packages/core/src/loop/types.ts b/packages/core/src/loop/types.ts new file mode 100644 index 000000000..ea011c436 --- /dev/null +++ b/packages/core/src/loop/types.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2026 protoCLI contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Minimum interval enforced by /loop. Mirrors Claude Code's 1-minute floor + * and matches the cron scheduler's 1-minute granularity. + */ +export const MIN_LOOP_INTERVAL_MS = 60 * 1000; + +/** + * Default interval used when /loop is invoked without an interval token. + */ +export const DEFAULT_LOOP_INTERVAL_MS = 10 * 60 * 1000; + +/** + * Argument tokens that stop / cancel all active loops. Accepted by + * `/loop `. + */ +export const LOOP_STOP_ALIASES = ['stop', 'off', 'clear', 'cancel'] as const; + +export type LoopStopAlias = (typeof LOOP_STOP_ALIASES)[number]; diff --git a/packages/core/src/skills/bundled/loop/SKILL.md b/packages/core/src/skills/bundled/loop/SKILL.md deleted file mode 100644 index ea9ae7cf3..000000000 --- a/packages/core/src/skills/bundled/loop/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: loop -description: Create a recurring loop that runs a prompt on a schedule. Usage - /loop 5m check the build, /loop check the PR every 30m, /loop run tests (defaults to 10m). /loop list to show jobs, /loop clear to cancel all. -allowedTools: - - cron_create - - cron_list - - cron_delete ---- - -# /loop — schedule a recurring prompt - -## Subcommands - -If the input (after stripping the `/loop` prefix) is exactly one of these keywords, run the subcommand instead of scheduling: - -- **`list`** — call CronList and display the results. Done. -- **`clear`** — call CronList, then call CronDelete for every job returned. Confirm how many were cancelled. Done. - -Otherwise, parse the input below into `[interval] ` and schedule it with CronCreate. - -## Parsing (in priority order) - -1. **Leading token**: if the first whitespace-delimited token matches `^\d+[smhd]$` (e.g. `5m`, `2h`), that's the interval; the rest is the prompt. -2. **Trailing "every" clause**: otherwise, if the input ends with `every ` or `every ` (e.g. `every 20m`, `every 5 minutes`, `every 2 hours`), extract that as the interval and strip it from the prompt. Only match when what follows "every" is a time expression — `check every PR` has no interval. -3. **Default**: otherwise, interval is `10m` and the entire input is the prompt. - -If the resulting prompt is empty, show usage `/loop [interval] ` and stop — do not call CronCreate. - -Examples: - -- `5m /babysit-prs` → interval `5m`, prompt `/babysit-prs` (rule 1) -- `check the deploy every 20m` → interval `20m`, prompt `check the deploy` (rule 2) -- `run tests every 5 minutes` → interval `5m`, prompt `run tests` (rule 2) -- `check the deploy` → interval `10m`, prompt `check the deploy` (rule 3) -- `check every PR` → interval `10m`, prompt `check every PR` (rule 3 — "every" not followed by time) -- `5m` → empty prompt → show usage - -## Interval → cron - -Supported suffixes: `s` (seconds, rounded up to nearest minute, min 1), `m` (minutes), `h` (hours), `d` (days). Convert: - -| Interval pattern | Cron expression | Notes | -| ----------------- | ---------------------- | ----------------------------------------- | -| `Nm` where N ≤ 59 | `*/N * * * *` | every N minutes | -| `Nm` where N ≥ 60 | `0 */H * * *` | round to hours (H = N/60, must divide 24) | -| `Nh` where N ≤ 23 | `0 */N * * *` | every N hours | -| `Nd` | `0 0 */N * *` | every N days at midnight local | -| `Ns` | treat as `ceil(N/60)m` | cron minimum granularity is 1 minute | - -**If the interval doesn't cleanly divide its unit** (e.g. `7m` → `*/7 * * * *` gives uneven gaps at :56→:00; `90m` → 1.5h which cron can't express), pick the nearest clean interval and tell the user what you rounded to before scheduling. - -## Action - -1. Call CronCreate with: - - `cron`: the expression from the table above - - `prompt`: the parsed prompt from above, verbatim (slash commands are passed through unchanged) - - `recurring`: `true` -2. Briefly confirm: what's scheduled, the cron expression, the human-readable cadence, that recurring tasks auto-expire after 3 days, and that they can cancel sooner with CronDelete (include the job ID). -3. **Then immediately execute the parsed prompt now** — don't wait for the first cron fire. If it's a slash command, invoke it via the Skill tool; otherwise act on it directly. - -## Input From 269f3b067eb610a2d461ea558823a20d1c76ed46 Mon Sep 17 00:00:00 2001 From: Automaker Date: Fri, 22 May 2026 17:44:38 -0700 Subject: [PATCH 2/2] fix(goal,loop): address review feedback from PR #257 - /goal evaluator now re-runs after goal-driven continuations. Previously the unmet-goal continuation used SendMessageType.Hook, which the next pass's gate excluded -- so /goal would inject feedback once and then stop. Added SendMessageType.GoalContinuation, drop the Hook-only gate on the goal block, and extend the CompletionChecker gate to also skip goal-driven continuations so they don't interleave. - Evaluator failures no longer abort a completed turn. evaluateGoal is a second model call after the user's turn already succeeded; if it throws we now log and skip recording, leaving the goal active and the user's turn output intact. - extractFirstJsonObject tracks in-string state and JSON escape sequences, so payloads like {"reason": "see {artifact}"} no longer break brace matching. - stripCodeFence replaces the lazy-quantifier regex (CodeQL polynomial regex finding) with a simple indexOf/lastIndexOf scan. Regression test verifies pathological whitespace input returns in under 100ms. - GoalManager.setGoal and clearGoal now return defensive copies so callers can't mutate internal state through the return value. getActiveGoal/getLastAchievedGoal already did this. - intervalToCron rounded check for near-24h inputs (e.g. 1439m) now compares back in minutes (days * 24 * 60 !== minutes) instead of hours / 24, so the rounding warning fires correctly. - docs/guides/goal.md corrects the "small fast model" wording to reference Config.getModel() with a note about the future fast-model switch, and adds language tags to fenced code blocks for MD040. Co-Authored-By: Claude Opus 4.7 --- docs/guides/goal.md | 11 ++- packages/core/src/core/client.ts | 87 ++++++++++++------- packages/core/src/goal/GoalManager.test.ts | 23 +++++ packages/core/src/goal/GoalManager.ts | 5 +- packages/core/src/goal/goalEvaluator.test.ts | 35 ++++++++ packages/core/src/goal/goalEvaluator.ts | 40 ++++++++- packages/core/src/loop/intervalToCron.test.ts | 14 +++ packages/core/src/loop/intervalToCron.ts | 4 +- 8 files changed, 179 insertions(+), 40 deletions(-) diff --git a/docs/guides/goal.md b/docs/guides/goal.md index e2c8437b5..12de35863 100644 --- a/docs/guides/goal.md +++ b/docs/guides/goal.md @@ -1,6 +1,9 @@ # Keep proto working toward a goal -Set a completion condition with `/goal` and proto keeps working across turns until the condition is met. After every turn a small fast model checks the transcript against your condition; if it isn't satisfied yet, proto starts another turn instead of returning control. The goal clears automatically once the condition is met. +Set a completion condition with `/goal` and proto keeps working across turns until the condition is met. After every turn the configured model (`Config.getModel()`) checks the transcript against your condition; if it isn't satisfied yet, proto starts another turn instead of returning control. The goal clears automatically once the condition is met. + +> [!note] +> Today the evaluator uses the same model your main turns use. When protoCLI exposes a small/fast-model accessor, this will switch over so evaluations are cheaper. Token cost shows up under "eval tokens" in `/goal` status. Use a goal for substantial work with a verifiable end state: @@ -13,7 +16,7 @@ Use a goal for substantial work with a verifiable end state: Run `/goal` followed by the condition you want satisfied. -``` +```text /goal all tests in test/auth pass and the lint step is clean ``` @@ -40,7 +43,7 @@ A good condition usually has: Run `/goal` with no arguments to inspect the current state. -``` +```text /goal ``` @@ -50,7 +53,7 @@ If a goal is active, the status shows the condition, how long it has been runnin Run `/goal clear` to remove an active goal before its condition is met. Any of `stop`, `off`, `reset`, `none`, and `cancel` are accepted as aliases for `clear`. Starting a new conversation with `/clear` also removes any active goal. -``` +```text /goal clear ``` diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index e012a3a2a..9e4b8bc2c 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -114,6 +114,12 @@ export enum SendMessageType { Hook = 'hook', /** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */ Cron = 'cron', + /** + * Continuation injected by the /goal evaluator when a condition isn't met + * yet. Distinguished from Hook so the goal evaluator re-runs after each + * goal-driven turn (Hook continuations are excluded from re-evaluation). + */ + GoalContinuation = 'goalContinuation', } export interface SendMessageOptions { @@ -949,7 +955,8 @@ export class GeminiClient { !turn.pendingToolCalls.length && signal && !signal.aborted && - messageType !== SendMessageType.Hook + messageType !== SendMessageType.Hook && + messageType !== SendMessageType.GoalContinuation ) { const completionHistory = this.getHistory(); const toolCallHistory = this.extractToolCallHistory(completionHistory); @@ -989,16 +996,25 @@ export class GeminiClient { // Evaluate any active /goal against the just-finished turn. If the // condition is not met, inject the evaluator's reason as guidance and // run another turn -- the same continuation pattern used above by the - // Stop hook and CompletionChecker paths. If met, mark achieved and let - // control return to the user. The optional-chaining call covers test - // mocks that don't stub getGoalManager. + // Stop hook and CompletionChecker paths. + // + // Unlike CompletionChecker, this block intentionally has no + // `messageType !== Hook` gate: the goal evaluator must run after every + // turn (including Stop-hook / CompletionChecker continuations) per the + // /goal spec. To distinguish goal-driven continuations from generic Hook + // ones (and prevent CompletionChecker from re-firing on top of them), + // continuations from this block carry SendMessageType.GoalContinuation. + // + // The optional-chaining call on getGoalManager covers test mocks that + // don't stub the method. Evaluator failures must NOT abort a completed + // turn; we wrap the model call in try/catch and skip recording on + // failure so the user's successful turn output still surfaces. const goalManager = this.config.getGoalManager?.(); if ( goalManager?.hasActiveGoal() && !turn.pendingToolCalls.length && signal && - !signal.aborted && - messageType !== SendMessageType.Hook + !signal.aborted ) { const goalHistory = this.getHistory(); const goalToolCalls = this.extractToolCallHistory(goalHistory); @@ -1025,32 +1041,45 @@ export class GeminiClient { return `- ${t.name} [${status}]${cmd}`; }) .join('\n'); - const evalResult = await evaluateGoal( - this.getContentGeneratorOrFail(), - this.config.getModel(), - { - condition: active.condition, - toolCallSummary, - lastAssistantMessage: lastGoalAssistantMessage, - }, - signal, - ); - goalManager.recordEvaluation(evalResult); - if (evalResult.met) { - goalManager.markAchieved(); - } else { - const continueReason = `Goal not yet met. Evaluator: ${evalResult.reason}\n\nKeep working toward: ${active.condition}`; - const continueRequest = [{ text: continueReason }]; - const goalResult = yield* this.sendMessageStream( - continueRequest, + let evalResult: Awaited> | undefined; + try { + evalResult = await evaluateGoal( + this.getContentGeneratorOrFail(), + this.config.getModel(), + { + condition: active.condition, + toolCallSummary, + lastAssistantMessage: lastGoalAssistantMessage, + }, signal, - prompt_id, - { type: SendMessageType.Hook }, - boundedTurns - 1, ); - if (ownsTurnSpan) endTurnSpan('ok'); - return goalResult; + } catch (err) { + this.config + .getDebugLogger() + .warn( + `[goal] evaluator threw for condition "${active.condition.slice(0, 60)}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (evalResult) { + goalManager.recordEvaluation(evalResult); + + if (evalResult.met) { + goalManager.markAchieved(); + } else { + const continueReason = `Goal not yet met. Evaluator: ${evalResult.reason}\n\nKeep working toward: ${active.condition}`; + const continueRequest = [{ text: continueReason }]; + const goalResult = yield* this.sendMessageStream( + continueRequest, + signal, + prompt_id, + { type: SendMessageType.GoalContinuation }, + boundedTurns - 1, + ); + if (ownsTurnSpan) endTurnSpan('ok'); + return goalResult; + } } } } diff --git a/packages/core/src/goal/GoalManager.test.ts b/packages/core/src/goal/GoalManager.test.ts index 87570f9e1..ec4941e22 100644 --- a/packages/core/src/goal/GoalManager.test.ts +++ b/packages/core/src/goal/GoalManager.test.ts @@ -152,4 +152,27 @@ describe('GoalManager', () => { expect(manager.getActiveGoal()?.turnCount).toBe(0); }); }); + + describe('return values are defensive copies', () => { + it('setGoal return value does not alias internal state', () => { + const returned = manager.setGoal('x'); + returned.turnCount = 999; + expect(manager.getActiveGoal()?.turnCount).toBe(0); + }); + + it('clearGoal return value does not alias internal state', () => { + manager.setGoal('y'); + manager.recordTurn(); + const cleared = manager.clearGoal(); + // Mutating the returned copy should not surface anywhere -- the + // manager has cleared its own state. This is a regression guard + // against the previous shape that returned the live reference. + if (cleared) cleared.condition = 'mutated'; + expect(manager.hasActiveGoal()).toBe(false); + // Re-setting + reading should give us the fresh condition, not the + // mutated one (proves the reference didn't leak elsewhere). + manager.setGoal('z'); + expect(manager.getActiveGoal()?.condition).toBe('z'); + }); + }); }); diff --git a/packages/core/src/goal/GoalManager.ts b/packages/core/src/goal/GoalManager.ts index 64a594d4e..50ee44ac0 100644 --- a/packages/core/src/goal/GoalManager.ts +++ b/packages/core/src/goal/GoalManager.ts @@ -49,7 +49,8 @@ export class GoalManager { turnCount: 0, tokensSpent: 0, }; - return this.active; + // Return a copy so callers can't mutate internal state. + return { ...this.active }; } /** @@ -58,7 +59,7 @@ export class GoalManager { */ clearGoal(): GoalState | undefined { if (!this.active) return undefined; - const cleared = this.active; + const cleared = { ...this.active }; this.active = undefined; debugLogger.info( `Cleared goal "${truncate(cleared.condition, 60)}" after ${cleared.turnCount} turns.`, diff --git a/packages/core/src/goal/goalEvaluator.test.ts b/packages/core/src/goal/goalEvaluator.test.ts index 5468b7ffd..1c3bea417 100644 --- a/packages/core/src/goal/goalEvaluator.test.ts +++ b/packages/core/src/goal/goalEvaluator.test.ts @@ -62,6 +62,41 @@ describe('parseEvaluatorJson', () => { const r = parseEvaluatorJson('{"met": "yes", "reason": "fuzzy"}'); expect(r.met).toBe(false); }); + + it('handles braces inside JSON string values', () => { + const r = parseEvaluatorJson( + '{"met": true, "reason": "see {artifact} for proof"}', + ); + expect(r.met).toBe(true); + expect(r.reason).toBe('see {artifact} for proof'); + }); + + it('handles escaped quotes inside JSON string values', () => { + const r = parseEvaluatorJson( + '{"met": false, "reason": "expected \\"PASS\\" but got \\"FAIL\\""}', + ); + expect(r.met).toBe(false); + expect(r.reason).toBe('expected "PASS" but got "FAIL"'); + }); + + it('handles fenced blocks with a language tag and trailing prose', () => { + const r = parseEvaluatorJson( + 'Here is my answer:\n```json\n{"met": true, "reason": "done"}\n```\nThat is all.', + ); + expect(r.met).toBe(true); + expect(r.reason).toBe('done'); + }); + + it('does not hang on pathological whitespace input', () => { + // Regression test for the ReDoS finding -- a pathological string that + // used to make the old regex backtrack should now return quickly. + const pathological = '```' + ' '.repeat(10_000); + const start = Date.now(); + const r = parseEvaluatorJson(pathological); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(100); + expect(r.met).toBe(false); + }); }); describe('evaluateGoal', () => { diff --git a/packages/core/src/goal/goalEvaluator.ts b/packages/core/src/goal/goalEvaluator.ts index 35fdd9570..b6607c353 100644 --- a/packages/core/src/goal/goalEvaluator.ts +++ b/packages/core/src/goal/goalEvaluator.ts @@ -139,18 +139,50 @@ export function parseEvaluatorJson( } } +/** + * Strip a surrounding ```...``` fence using simple string scans. Avoids a + * lazy-quantifier regex that CodeQL flags as polynomial on input full of + * whitespace or partial fences. + */ function stripCodeFence(text: string): string { - const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); - return fenced ? fenced[1].trim() : text.trim(); + const trimmed = text.trim(); + if (!trimmed.startsWith('```')) return trimmed; + // Skip the opening fence and an optional language tag on the same line. + const firstNewline = trimmed.indexOf('\n'); + if (firstNewline === -1) return trimmed; + const closing = trimmed.lastIndexOf('```'); + if (closing <= firstNewline) return trimmed; + return trimmed.slice(firstNewline + 1, closing).trim(); } +/** + * Find the first balanced JSON object in `text`. Tracks string state so braces + * inside JSON strings (e.g. `{"reason": "see {artifact}"}`) don't desync the + * brace counter, and honours standard JSON string escapes. + */ function extractFirstJsonObject(text: string): string | null { const start = text.indexOf('{'); if (start === -1) return null; let depth = 0; + let inString = false; + let escapeNext = false; for (let i = start; i < text.length; i++) { - if (text[i] === '{') depth++; - else if (text[i] === '}') { + const ch = text[i]; + if (escapeNext) { + escapeNext = false; + continue; + } + if (inString) { + if (ch === '\\') escapeNext = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') { depth--; if (depth === 0) return text.slice(start, i + 1); } diff --git a/packages/core/src/loop/intervalToCron.test.ts b/packages/core/src/loop/intervalToCron.test.ts index 28ce8170b..9627ab236 100644 --- a/packages/core/src/loop/intervalToCron.test.ts +++ b/packages/core/src/loop/intervalToCron.test.ts @@ -59,6 +59,20 @@ describe('intervalToCron — days', () => { it('7d → 0 0 */7 * *', () => { expect(intervalToCron('7d').cron).toBe('0 0 */7 * *'); }); + + it('reports rounded=true for near-24h inputs that crossed the day boundary', () => { + // 1439 minutes ≈ 23h59m, which gets rounded to "1 day at midnight". + // The old check (`days !== hours / 24`) missed this; this regression + // test confirms the corrected comparison reports the rounding. + const r = intervalToCron('1439m'); + expect(r.cron).toBe('0 0 */1 * *'); + expect(r.rounded).toBe(true); + }); + + it('does not falsely mark exact day multiples as rounded', () => { + expect(intervalToCron('1d').rounded).toBe(false); + expect(intervalToCron('2d').rounded).toBe(false); + }); }); describe('intervalMsToCron', () => { diff --git a/packages/core/src/loop/intervalToCron.ts b/packages/core/src/loop/intervalToCron.ts index f1c8cbd78..ffdb4fcba 100644 --- a/packages/core/src/loop/intervalToCron.ts +++ b/packages/core/src/loop/intervalToCron.ts @@ -71,7 +71,9 @@ export function intervalMsToCron(ms: number): IntervalToCronResult { return { cron: `0 0 */${days} * *`, description: `every ${days} day${days === 1 ? '' : 's'} at midnight`, - rounded: days !== hours / 24, + // Compare back in minutes -- `hours !== days * 24` would miss the case + // where `minutes` got rounded into `hours` upstream. This catches both. + rounded: days * 24 * 60 !== minutes, }; }