Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2bacaf7
feat(goal): let the model propose a Goal the user approves in a dialog
qqqys Aug 26, 2026
e2c77fd
docs: size the goals.modelProposed settings row to the existing table…
qqqys Aug 26, 2026
549975c
fix(cli): localize propose goal tool
qqqys Aug 26, 2026
e367c4b
fix(goal): apply an approved propose_goal proposal when the proposing…
qqqys Aug 27, 2026
ba945f3
Merge remote-tracking branch 'origin/feat/propose-goal-tool' into fea…
qqqys Aug 27, 2026
10c6fab
fix(core): harden proposed goal settlement
qqqys Aug 27, 2026
1e4acf2
fix(core): harden proposed Goal approval
qqqys Aug 27, 2026
dd9cef7
Merge upstream/main into feat/propose-goal-tool
qqqys Aug 29, 2026
751799f
test(goal): pin the propose_goal lifecycle behaviors the review found…
qqqys Aug 29, 2026
733463f
Merge branch 'main' into feat/propose-goal-tool
qwen-code-dev-bot Aug 29, 2026
c69264d
Merge branch 'main' into feat/propose-goal-tool
qwen-code-dev-bot Aug 29, 2026
ebcbb79
Merge branch 'main' into feat/propose-goal-tool
qwen-code-dev-bot Aug 29, 2026
2167bd3
fix(goal): bind a propose_goal approval to the turn that made it
qqqys Aug 30, 2026
808173d
fix(goal): preserve proposal ownership across turns
qqqys Aug 30, 2026
210baa4
Merge upstream/main into feat/propose-goal-tool
qqqys Aug 30, 2026
9d54eab
chore: merge upstream/main into feat/propose-goal-tool
qqqys Sep 1, 2026
b69c986
chore(goal): merge upstream/main into feat/propose-goal-tool
qwen-code-dev-bot Sep 1, 2026
12bb62c
chore: merge upstream/main into feat/propose-goal-tool
qqqys Sep 1, 2026
5c640ce
Merge remote-tracking branch 'upstream/main' into feat/propose-goal-tool
qqqys Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/design/2026-08-25-goal-draft-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ The objective is handed over on one line because `parseGoalCommand` splits on wh
- `docs/users/features/goals.md` (commands, how a Goal is judged, writing a good objective, `/goal-draft`), rows in `commands.md`, a pointer from `headless.md`.
- The web-shell Goals dialog placeholder now shows an objective with a check, a guardrail, and a budget in both locales.

## Phase 2: `propose_goal`

A core tool, registered beside `get_goal` / `update_goal` (so never for subagents), only when the host can show a dialog (`resolveInteractionMode !== 'headless'`) and `goals.modelProposed` is not `disabled`. It reuses the generic `info` confirmation: the objective is in both the invocation description (the one field every host forwards — the Web Shell drops an `info` prompt) and the plain-text prompt, and `requiresUserInteraction()` is `true` so no allow rule, YOLO, or AUTO_EDIT (which auto-approves `info` confirmations) can skip the dialog. Preconditions are checked before the dialog and again in `execute()`, because `/goal` can change the session while the dialog is open: plan mode, untrusted folder, no Goal persistence, and an active Goal all refuse with guidance (an active Goal is never replaced from the tool; the model hands over a `/goal edit` / `/goal set` line instead). A stopped Goal is replaced through `replace` with its expected version; no Goal creates. The tool does not dispatch at all: setting a Goal mid-turn would leave the rest of the proposing turn without a Goal permit (`client.ts` rejects a permit-less continuation while a Goal is active — the first end-to-end run showed exactly that error card). Instead the approval is parked on `Config` (`setPendingGoalProposal`) and the client applies it at the same boundary a typed `/goal set` takes effect: once the model stops calling tools, before Stop hooks (`settlePendingGoalProposal` → `applyPendingGoalProposal`). The runtime's broadcast then renders the Goal card and starts the first Goal turn. An approval still parked when the next real user query starts belongs to a cancelled turn and is discarded; the tool result tells the model to acknowledge in one sentence and stop.

`goals.modelProposed` (`alwaysAsk` | `disabled`, default `alwaysAsk`) sits in `WORKSPACE_RESTRICTED_SETTINGS`, so a repository cannot switch the tool on. An `auto` mode that lets the model skip the dialog (Claude Code's `ask_user: false`) is deliberately not offered.

The skill's hand-off now prefers the tool when it is available and no Goal is active, and keeps the printed `/goal set` line for headless runs, the disabled setting, and the active-Goal case.

## Later phases (not in this change)

- A `propose_goal` core tool with an approval dialog, mirroring Claude Code's `ProposeGoal` + `modelProposedGoals` (read from user/policy settings only), so the skill can offer "Set this goal" instead of a line to paste. `parseGoalCommand` keeps newlines for `set`/`edit`.
- `parseGoalCommand` keeps newlines for `set`/`edit`.
- A deterministic lint on `/goal set` (rules 1–6 above) that warns and points at `/goal-draft`, and a "refine" entry in the web-shell Goals dialog.

## Verification
Expand Down
1 change: 1 addition & 0 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
| `tools.useRipgrep` | boolean | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | |
| `tools.useBuiltinRipgrep` | boolean | Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. | `true` | |
| `tools.workflowsEnabled` | boolean | Enable the Workflow tool, which lets the model author and run a script that orchestrates subagents in parallel. Off by default; a run can dispatch many subagents and spend tokens accordingly. | `false` | User, System, and SystemDefaults scopes only; workspace values are ignored. Requires restart: Yes. Env overrides: `QWEN_CODE_ENABLE_WORKFLOWS=1` forces on; `QWEN_CODE_DISABLE_WORKFLOWS=1` forces off (disable wins). |
| `goals.modelProposed` | enum | Controls the `propose_goal` tool, which lets the model propose a session Goal for you to approve: `alwaysAsk` shows every proposal in an approval dialog and nothing is set until you accept it; `"disabled"` removes the tool. A typed `/goal` is unaffected. | `alwaysAsk` | User, System, and SystemDefaults scopes only; workspace values are ignored. Requires restart: Yes. |
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
| `tools.toolSearch.enabled` | boolean | Load MCP tools on demand via ToolSearch to reduce prompt size. Disable this for models that rely on prefix-based KV caching (e.g. DeepSeek) to keep the prompt prefix stable and maximize cache hit rates. | `true` | Requires restart: Yes |
Expand Down
8 changes: 7 additions & 1 deletion docs/users/features/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,14 @@ Keep it to one objective and roughly under 1,200 characters. `/goal set` and `/g

## Let `/goal-draft` write it

`/goal-draft <what you want done>` is a bundled skill that does the above for you. It checks whether the request is a Goal at all, reads the workspace for the real test and lint commands instead of guessing, asks at most one round of multiple-choice questions when the answer changes the check or the scope, drafts the objective in the format above, runs the self-check, and prints a `/goal set …` line you can run as-is. It never starts the work itself and never sets the Goal on your behalf.
`/goal-draft <what you want done>` is a bundled skill that does the above for you. It checks whether the request is a Goal at all, reads the workspace for the real test and lint commands instead of guessing, asks at most one round of multiple-choice questions when the answer changes the check or the scope, drafts the objective in the format above, runs the self-check, and hands it over: in an interactive session it proposes the objective through the `propose_goal` approval dialog described below, otherwise it prints a `/goal set …` line you can run as-is. It never starts the work itself, and nothing is set without your approval.

Pass an existing objective to tighten it: `/goal-draft all tests pass and the lint is clean`.

### Approve a Goal the model proposes

In an interactive session (TUI, Web Shell, or an ACP client) the model has a `propose_goal` tool. When `/goal-draft` finishes, or when you ask for an outcome that spans several turns, it can propose the objective instead of printing a `/goal set …` line for you to copy. The proposal appears as an approval dialog showing the full objective. Approving it sets the Goal exactly as `/goal set` would, the moment the current turn ends (the model acknowledges and stops; the first Goal turn then starts on its own), and declining sets nothing — the model is told only that the Goal was not set, and must not propose it again. If you cancel the turn after approving, the approval is dropped rather than applied under your next message. No permission rule or approval mode (including YOLO) skips this dialog, and the tool refuses while another Goal is active, in plan mode, in subagents, and in untrusted folders. It is not available in headless runs, where the printed `/goal set` line remains the hand-off.

Turn it off with `goals.modelProposed: "disabled"` in your user settings. Because the setting decides whether the model may ask you to start an autonomous loop, it is honored only from user and system scope; a workspace `.qwen/settings.json` value is ignored with a warning.

The skill is instructed to be read-only, and only its non-mutating tools are auto-approved (`get_goal`, `read_file`, `glob`, `grep_search`). `ask_user_question` is deliberately not auto-approved, so its question dialog is shown before the skill drafts from your answers. Like other bundled skills, a project or personal skill named `goal-draft` overrides it, and `skills.disabled` can turn it off. See [Skills](./skills.md) for how bundled skills are discovered.
13 changes: 13 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Storage,
SessionIdCaseConflictError,
} from '@qwen-code/qwen-code-core';
import { normalizeModelProposedGoals } from './config.js';
import {
isValidSessionId,
loadCliConfig,
Expand Down Expand Up @@ -5446,3 +5447,15 @@ describe('loadCliConfig skills.disabledLevels', () => {
expect(config.getDisabledSkillLevels()).toEqual(new Set());
});
});

describe('normalizeModelProposedGoals', () => {
it('passes the two known modes through and drops anything else', () => {
expect(normalizeModelProposedGoals('alwaysAsk')).toBe('alwaysAsk');
expect(normalizeModelProposedGoals('disabled')).toBe('disabled');
// An unknown value must not reach core as a third mode; the core
// default (alwaysAsk) applies instead.
expect(normalizeModelProposedGoals('auto')).toBeUndefined();
expect(normalizeModelProposedGoals(undefined)).toBeUndefined();
expect(normalizeModelProposedGoals(true)).toBeUndefined();
});
});
15 changes: 15 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import {
type ModelProposedGoalsMode,
ApprovalMode,
APPROVAL_MODE_INFO,
APPROVAL_MODES,
Expand Down Expand Up @@ -1497,6 +1498,17 @@ export class SessionIdConflictError extends Error {
}
}

/**
* `goals.modelProposed` reaches core as a closed enum. Anything else in the
* settings file (a typo, an older value) falls back to the default rather
* than smuggling an unknown mode through.
*/
export function normalizeModelProposedGoals(
value: unknown,
): ModelProposedGoalsMode | undefined {
return value === 'alwaysAsk' || value === 'disabled' ? value : undefined;
}

export async function loadCliConfig(
settings: Settings,
argv: CliArgs,
Expand Down Expand Up @@ -2363,6 +2375,9 @@ export async function loadCliConfig(
useRipgrep: settings.tools?.useRipgrep,
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
workflowsEnabled: settings.tools?.workflowsEnabled,
modelProposedGoals: normalizeModelProposedGoals(
settings.goals?.modelProposed,
),
Comment thread
qqqys marked this conversation as resolved.
Resolved
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
shellDefaultTimeoutMs: settings.tools?.shell?.defaultTimeoutMs,
shellHeartbeatIntervalMs: settings.tools?.shell?.heartbeatIntervalMs,
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3493,6 +3493,51 @@ describe('Settings Loading and Merging', () => {
});
});

describe('goals.modelProposed scope handling', () => {
it('is listed as workspace-restricted', () => {
expect(WORKSPACE_RESTRICTED_SETTING_KEYS).toContain(
'goals.modelProposed',
);
});

it('honors goals.modelProposed from user scope', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify({ goals: { modelProposed: 'disabled' } });
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.goals?.modelProposed).toBe('disabled');
});

it('strips goals.modelProposed from workspace scope and warns', () => {
// A repository must not be able to switch on a tool that asks the
// user to start an autonomous loop; the default is the user's call.
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify({ goals: { modelProposed: 'disabled' } });
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
// The workspace key was dropped before merging (merged settings do not
// materialise schema defaults, so nothing set means undefined, and the
// core default of alwaysAsk applies downstream).
expect(settings.merged.goals?.modelProposed).toBeUndefined();
const warnings = getSettingsWarnings(settings);
expect(warnings.some((w) => w.includes('goals.modelProposed'))).toBe(
true,
);
});
});

describe('cross-session settings scope handling', () => {
it('should honor the cross-session keys from user scope', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3232,6 +3232,31 @@ const SETTINGS_SCHEMA = {
},
},

goals: {
type: 'object',
label: 'Goals',
category: 'Advanced',
requiresRestart: true,
default: {},
description: 'Settings for session Goals (/goal).',
showInDialog: false,
properties: {
modelProposed: {
type: 'enum',
label: 'Model-Proposed Goals',
category: 'Advanced',
requiresRestart: true,
default: 'alwaysAsk',
description:
'Controls the propose_goal tool, which lets the model propose a session Goal for you to approve. "alwaysAsk" (default) shows every proposal in an approval dialog and nothing is set until you accept it; "disabled" removes the tool. A typed /goal is unaffected. Consent-affecting, so this setting is only honored from User, System, or SystemDefaults scope; workspace values are ignored.',
showInDialog: true,
options: [
{ value: 'alwaysAsk', label: 'Always ask' },
{ value: 'disabled', label: 'Disabled' },
],
},
},
},
agents: {
type: 'object',
label: 'Agents',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/settingsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export const WORKSPACE_RESTRICTED_SETTINGS = [
{ section: 'security', key: 'allowedInsecureVoiceBaseUrls' },
{ section: 'agents', key: 'crossSessionMessaging' },
{ section: 'agents', key: 'crossSessionInbound' },
{ section: 'goals', key: 'modelProposed' },
] as const satisfies ReadonlyArray<{
readonly section: keyof Settings;
readonly key: string;
Expand Down
Loading
Loading